diff --git a/Gemfile b/Gemfile index 42c6e235359..0179b2f46ff 100644 --- a/Gemfile +++ b/Gemfile @@ -34,7 +34,7 @@ gem 'foundation_emails' gem 'good_job', '~> 3.0' gem 'hashie', '~> 4.1' gem 'http_accept_language' -gem 'identity-hostdata', github: '18F/identity-hostdata', tag: 'v3.4.2' +gem 'identity-hostdata', github: '18F/identity-hostdata', tag: 'v3.4.3' gem 'identity-logging', github: '18F/identity-logging', tag: 'v0.1.0' gem 'identity_validations', github: '18F/identity-validations', tag: 'v0.7.2' gem 'jsbundling-rails', '~> 1.1.2' @@ -69,7 +69,7 @@ gem 'rqrcode' gem 'ruby-progressbar' gem 'ruby-saml' gem 'safe_target_blank', '>= 1.0.2' -gem 'saml_idp', github: '18F/saml_idp', tag: '0.18.2-18f' +gem 'saml_idp', github: '18F/saml_idp', tag: '0.18.3-18f' gem 'scrypt' gem 'simple_form', '>= 5.0.2' gem 'stringex', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 20acfbd4e2d..af282c95219 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ GIT remote: https://github.com/18F/identity-hostdata.git - revision: 9e2e0441cd93307cbfc5d5b8d4b3b7b4219394fb - tag: v3.4.2 + revision: 42027a05a3827177d473a0f2d998771011fc4fd6 + tag: v3.4.3 specs: - identity-hostdata (3.4.2) + identity-hostdata (3.4.3) activesupport (>= 6.1, < 8) aws-sdk-s3 (~> 1.8) @@ -34,10 +34,10 @@ GIT GIT remote: https://github.com/18F/saml_idp.git - revision: 5d9a9b0411e3bd79bf1159c94293ec55053884d4 - tag: 0.18.2-18f + revision: 26d550cd249e52304aecbb53add32cbec4001e2f + tag: 0.18.3-18f specs: - saml_idp (0.18.2.pre.18f) + saml_idp (0.18.3.pre.18f) activesupport builder faraday diff --git a/app/assets/stylesheets/components/_index.scss b/app/assets/stylesheets/components/_index.scss index 0c37bdfb6b8..d9a3bed2f35 100644 --- a/app/assets/stylesheets/components/_index.scss +++ b/app/assets/stylesheets/components/_index.scss @@ -16,7 +16,6 @@ @forward 'modal'; @forward 'nav'; @forward 'page-heading'; -@forward 'password'; @forward 'profile-section'; @forward 'personal-key'; @forward 'radio-button'; diff --git a/app/components/password_confirmation_component.html.erb b/app/components/password_confirmation_component.html.erb index 49014e370ad..638293ef292 100644 --- a/app/components/password_confirmation_component.html.erb +++ b/app/components/password_confirmation_component.html.erb @@ -43,4 +43,5 @@ > <%= t('components.password_confirmation.toggle_label') %> + <%= render PasswordStrengthComponent.new(input_id:, forbidden_passwords:) %> <% end %> diff --git a/app/components/password_confirmation_component.rb b/app/components/password_confirmation_component.rb index 508188f5649..afb2f909b01 100644 --- a/app/components/password_confirmation_component.rb +++ b/app/components/password_confirmation_component.rb @@ -1,17 +1,19 @@ class PasswordConfirmationComponent < BaseComponent - attr_reader :form, :field_options, :tag_options + attr_reader :form, :field_options, :forbidden_passwords, :tag_options def initialize( form:, password_label: nil, confirmation_label: nil, field_options: {}, + forbidden_passwords: [], **tag_options ) @form = form @password_label = password_label @confirmation_label = confirmation_label @field_options = field_options + @forbidden_passwords = forbidden_passwords @tag_options = tag_options end diff --git a/app/components/password_strength_component.html.erb b/app/components/password_strength_component.html.erb new file mode 100644 index 00000000000..d7263054c6f --- /dev/null +++ b/app/components/password_strength_component.html.erb @@ -0,0 +1,18 @@ +<%= content_tag( + :'lg-password-strength', + 'input-id': input_id, + 'minimum-length': minimum_length, + 'forbidden-passwords': forbidden_passwords.to_json, + **tag_options, + class: [*tag_options[:class], 'display-none'], + ) do %> +
+
+
+
+
+
+ <%= t('instructions.password.strength.intro') %> + +
+<% end %> diff --git a/app/components/password_strength_component.rb b/app/components/password_strength_component.rb new file mode 100644 index 00000000000..6d431a49ba3 --- /dev/null +++ b/app/components/password_strength_component.rb @@ -0,0 +1,15 @@ +class PasswordStrengthComponent < BaseComponent + attr_reader :input_id, :forbidden_passwords, :minimum_length, :tag_options + + def initialize( + input_id:, + minimum_length: Devise.password_length.min, + forbidden_passwords: [], + **tag_options + ) + @input_id = input_id + @minimum_length = minimum_length + @forbidden_passwords = forbidden_passwords + @tag_options = tag_options + end +end diff --git a/app/assets/stylesheets/components/_password.scss b/app/components/password_strength_component.scss similarity index 58% rename from app/assets/stylesheets/components/_password.scss rename to app/components/password_strength_component.scss index 34ea8217bb2..e7c89578ac2 100644 --- a/app/assets/stylesheets/components/_password.scss +++ b/app/components/password_strength_component.scss @@ -16,19 +16,27 @@ margin-left: units(1); } - .pw-weak &:nth-child(-n + 1) { + lg-password-strength[score='1'] &:nth-child(-n + 1) { background-color: color('error'); } - .pw-average &:nth-child(-n + 2) { + lg-password-strength[score='2'] &:nth-child(-n + 2) { background-color: color('warning'); } - .pw-good &:nth-child(-n + 3) { + lg-password-strength[score='3'] &:nth-child(-n + 3) { background-color: color('success-light'); } - .pw-great &:nth-child(-n + 4) { + lg-password-strength[score='4'] &:nth-child(-n + 4) { background-color: color('success'); } } + +.password-strength__strength { + @include u-text(bold); +} + +.password-strength__feedback { + @include u-text(italic); +} diff --git a/app/components/password_strength_component.ts b/app/components/password_strength_component.ts new file mode 100644 index 00000000000..1ef5aee95d7 --- /dev/null +++ b/app/components/password_strength_component.ts @@ -0,0 +1 @@ +import '@18f/identity-password-strength/password-strength-element'; diff --git a/app/components/password_toggle_component.rb b/app/components/password_toggle_component.rb index 539da619f2f..82a1ad840b5 100644 --- a/app/components/password_toggle_component.rb +++ b/app/components/password_toggle_component.rb @@ -28,6 +28,6 @@ def toggle_id end def input_id - "password-toggle-input-#{unique_id}" + field_options.dig(:input_html, :id) || "password-toggle-input-#{unique_id}" end end diff --git a/app/controllers/concerns/saml_idp_auth_concern.rb b/app/controllers/concerns/saml_idp_auth_concern.rb index 86cc4e84996..14b2be3d042 100644 --- a/app/controllers/concerns/saml_idp_auth_concern.rb +++ b/app/controllers/concerns/saml_idp_auth_concern.rb @@ -5,7 +5,7 @@ module SamlIdpAuthConcern included do # rubocop:disable Rails/LexicallyScopedActionFilter - before_action :validate_saml_request, only: :auth + before_action :validate_and_create_saml_request_object, only: :auth before_action :validate_service_provider_and_authn_context, only: :auth before_action :check_sp_active, only: :auth before_action :log_external_saml_auth_request, only: [:auth] @@ -45,21 +45,29 @@ def check_sp_active end def validate_service_provider_and_authn_context - @saml_request_validator = SamlRequestValidator.new + return if result.success? + + analytics.saml_auth( + **result.to_h.merge(request_signed: saml_request.signed?), + ) + render 'saml_idp/auth/error', status: :bad_request + end - @result = @saml_request_validator.call( + def result + @result ||= @saml_request_validator.call( service_provider: saml_request_service_provider, authn_context: requested_authn_contexts, authn_context_comparison: saml_request.requested_authn_context_comparison, nameid_format: name_id_format, ) + end - return if @result.success? - - analytics.saml_auth( - **@result.to_h.merge(request_signed: saml_request.signed?), - ) - render 'saml_idp/auth/error', status: :bad_request + def validate_and_create_saml_request_object + # this saml_idp method creates the saml_request object used for validations + validate_saml_request + @saml_request_validator = SamlRequestValidator.new + rescue SamlIdp::XMLSecurity::SignedDocument::ValidationError + @saml_request_validator = SamlRequestValidator.new(blank_cert: true) end def name_id_format diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index 2387631005b..ce5d5190848 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -118,7 +118,7 @@ def pii_requested_but_locked? end def capture_analytics - analytics_payload = @result.to_h.merge( + analytics_payload = result.to_h.merge( endpoint: api_saml_auth_path(path_year: params[:path_year]), idv: identity_needs_verification?, finish_profile: user_has_pending_profile?, diff --git a/app/controllers/users/webauthn_controller.rb b/app/controllers/users/webauthn_controller.rb index bd1a121b7c2..58d4fea1322 100644 --- a/app/controllers/users/webauthn_controller.rb +++ b/app/controllers/users/webauthn_controller.rb @@ -6,6 +6,7 @@ class WebauthnController < ApplicationController before_action :confirm_recently_authenticated_2fa before_action :set_form before_action :validate_configuration_exists + before_action :set_presenter def edit; end @@ -15,7 +16,7 @@ def update analytics.webauthn_update_name_submitted(**result.to_h) if result.success? - flash[:success] = t('two_factor_authentication.webauthn_platform.renamed') + flash[:success] = presenter.rename_success_alert_text redirect_to account_path else flash.now[:error] = result.first_error_message @@ -29,7 +30,7 @@ def destroy analytics.webauthn_delete_submitted(**result.to_h) if result.success? - flash[:success] = t('two_factor_authentication.webauthn_platform.deleted') + flash[:success] = presenter.delete_success_alert_text create_user_event(:webauthn_key_removed) revoke_remember_device(current_user) event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) @@ -49,6 +50,14 @@ def form alias_method :set_form, :form + delegate :configuration, to: :form + + def presenter + @presenter ||= TwoFactorAuthentication::WebauthnEditPresenter.new(configuration:) + end + + alias_method :set_presenter, :presenter + def form_class case action_name when 'edit', 'update' @@ -59,7 +68,7 @@ def form_class end def validate_configuration_exists - render_not_found if form.configuration.blank? + render_not_found if configuration.blank? end end end diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb index a9b64774af4..298fc37f259 100644 --- a/app/controllers/users/webauthn_setup_controller.rb +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -156,15 +156,19 @@ def handle_successful_delete else flash[:success] = t('notices.webauthn_deleted') end - track_delete(true) + track_delete(success: true, platform_authenticator: webauthn.platform_authenticator?) end def handle_failed_delete - track_delete(false) + track_delete(success: false, platform_authenticator: nil) end - def track_delete(success) - analytics.webauthn_delete_submitted(success:, configuration_id: delete_params[:id]) + def track_delete(success:, platform_authenticator:) + analytics.webauthn_delete_submitted( + success:, + configuration_id: delete_params[:id], + platform_authenticator:, + ) end def save_challenge_in_session diff --git a/app/forms/two_factor_authentication/webauthn_delete_form.rb b/app/forms/two_factor_authentication/webauthn_delete_form.rb index a85049ba20a..73756f39434 100644 --- a/app/forms/two_factor_authentication/webauthn_delete_form.rb +++ b/app/forms/two_factor_authentication/webauthn_delete_form.rb @@ -51,7 +51,10 @@ def validate_has_multiple_mfa end def extra_analytics_attributes - { configuration_id: } + { + configuration_id:, + platform_authenticator: configuration&.platform_authenticator?, + } end end end diff --git a/app/forms/two_factor_authentication/webauthn_update_form.rb b/app/forms/two_factor_authentication/webauthn_update_form.rb index 67e92787228..abb537eb7fd 100644 --- a/app/forms/two_factor_authentication/webauthn_update_form.rb +++ b/app/forms/two_factor_authentication/webauthn_update_form.rb @@ -62,7 +62,10 @@ def validate_unique_name end def extra_analytics_attributes - { configuration_id: } + { + configuration_id:, + platform_authenticator: configuration&.platform_authenticator?, + } end end end diff --git a/app/javascript/packages/build-sass/CHANGELOG.md b/app/javascript/packages/build-sass/CHANGELOG.md index c66c5e6ec55..f11528a0f98 100644 --- a/app/javascript/packages/build-sass/CHANGELOG.md +++ b/app/javascript/packages/build-sass/CHANGELOG.md @@ -7,6 +7,8 @@ ### Improvements - `--out-dir` is now optional. If omitted, files will be output in the same directory as their source files. +- The command-line tool now uses [Sass Shared Resources API](https://github.com/sass/sass/blob/main/accepted/shared-resources.d.ts.md), improving performance when compiling multiple files that share common resources. + - In Login.gov's identity provider application, this reduced compilation times by an average of 66%! ## 2.0.0 diff --git a/app/javascript/packages/build-sass/README.md b/app/javascript/packages/build-sass/README.md index d2d5409d1b3..31e1003cff3 100644 --- a/app/javascript/packages/build-sass/README.md +++ b/app/javascript/packages/build-sass/README.md @@ -44,6 +44,7 @@ function buildFile( options: { outDir: string, optimize: boolean, + sassCompiler: SassAsyncCompiler, ...sassOptions: SassOptions<'sync'>, }, ): Promise; diff --git a/app/javascript/packages/build-sass/cli.js b/app/javascript/packages/build-sass/cli.js index f755e384526..c16e3871df0 100755 --- a/app/javascript/packages/build-sass/cli.js +++ b/app/javascript/packages/build-sass/cli.js @@ -4,8 +4,9 @@ import { mkdir } from 'node:fs/promises'; import { parseArgs } from 'node:util'; +import { fileURLToPath } from 'node:url'; import { watch } from 'chokidar'; -import { fileURLToPath } from 'url'; +import { initAsyncCompiler as initAsyncSassCompiler } from 'sass-embedded'; import { buildFile } from './index.js'; import getDefaultLoadPaths from './get-default-load-paths.js'; import getErrorSassStackPaths from './get-error-sass-stack-paths.js'; @@ -29,8 +30,10 @@ const { values: flags, positionals: fileArgs } = parseArgs({ const { watch: isWatching, 'out-dir': outDir, 'load-path': loadPaths = [] } = flags; loadPaths.push(...getDefaultLoadPaths()); +const sassCompiler = await initAsyncSassCompiler(); + /** @type {BuildOptions & SyncSassOptions} */ -const options = { outDir, loadPaths, optimize: isProduction }; +const options = { outDir, loadPaths, sassCompiler, optimize: isProduction }; /** * Watches given file path(s), triggering the callback on the first change. @@ -89,4 +92,8 @@ try { } catch (error) { console.error(error); process.exitCode = 1; +} finally { + if (!isWatching) { + await sassCompiler.dispose(); + } } diff --git a/app/javascript/packages/build-sass/index.js b/app/javascript/packages/build-sass/index.js index 38dca4cb5c1..8722afaadee 100644 --- a/app/javascript/packages/build-sass/index.js +++ b/app/javascript/packages/build-sass/index.js @@ -2,10 +2,11 @@ import { basename, join, dirname } from 'node:path'; import { createWriteStream } from 'node:fs'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { compile as sassCompile } from 'sass-embedded'; +import { compileAsync as baseSassCompileAsync } from 'sass-embedded'; import { transform as lightningTransform, browserslistToTargets } from 'lightningcss'; import browserslist from 'browserslist'; +/** @typedef {import('sass-embedded').AsyncCompiler} AsyncCompiler */ /** @typedef {import('sass-embedded').CompileResult} CompileResult */ /** @typedef {import('sass-embedded').Options<'sync'>} SyncSassOptions */ @@ -14,6 +15,7 @@ import browserslist from 'browserslist'; * * @prop {string=} outDir Output directory. * @prop {boolean} optimize Whether to optimize output for production. + * @prop {AsyncCompiler=} sassCompiler Sass compiler to use, particularly useful with initCompiler */ const TARGETS = browserslistToTargets( @@ -29,8 +31,18 @@ const TARGETS = browserslistToTargets( * @return {Promise} */ export async function buildFile(file, options) { - const { outDir = dirname(file), optimize, loadPaths = [], ...sassOptions } = options; - const sassResult = sassCompile(file, { + const { + outDir = dirname(file), + optimize, + loadPaths = [], + sassCompiler, + ...sassOptions + } = options; + const sassCompile = sassCompiler + ? sassCompiler.compileAsync.bind(sassCompiler) + : baseSassCompileAsync; + + const sassResult = await sassCompile(file, { style: optimize ? 'compressed' : 'expanded', ...sassOptions, loadPaths: [...loadPaths, 'node_modules'], diff --git a/app/javascript/packages/build-sass/package.json b/app/javascript/packages/build-sass/package.json index f70d1b41d28..7e7516fc98c 100644 --- a/app/javascript/packages/build-sass/package.json +++ b/app/javascript/packages/build-sass/package.json @@ -31,6 +31,6 @@ "browserslist": "^4.22.1", "chokidar": "^3.5.3", "lightningcss": "^1.22.0", - "sass-embedded": "^1.69.2" + "sass-embedded": "^1.70.0" } } diff --git a/app/javascript/packages/document-capture/components/suspense-error-boundary.jsx b/app/javascript/packages/document-capture/components/suspense-error-boundary.jsx deleted file mode 100644 index f04f71a0581..00000000000 --- a/app/javascript/packages/document-capture/components/suspense-error-boundary.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Component, Suspense } from 'react'; - -/** @typedef {import('react').ReactNode} ReactNode */ -/** @typedef {import('react').FunctionComponent} FunctionComponent */ - -/** - * @typedef SuspenseErrorBoundaryProps - * - * @prop {NonNullable|null} fallback Fallback to show while suspense pending. - * @prop {(error: Error)=>void} onError Error callback. - * @prop {Error=} handledError Error instance caught to allow for acknowledgment of rerender, in - * order to prevent infinite rerendering. - * @prop {ReactNode} children Suspense child. - */ - -/** - * @extends {Component} - */ -class SuspenseErrorBoundary extends Component { - constructor(props) { - super(props); - - this.state = { hasError: false }; - } - - static getDerivedStateFromError(error) { - return { - hasError: true, - error, - }; - } - - componentDidCatch(error) { - const { onError } = this.props; - onError(error); - } - - render() { - const { fallback, children, handledError } = this.props; - const { hasError, error } = this.state; - - if (hasError && error !== handledError) { - return null; - } - - return {children}; - } -} - -export default SuspenseErrorBoundary; diff --git a/app/javascript/packages/document-capture/components/suspense-error-boundary.tsx b/app/javascript/packages/document-capture/components/suspense-error-boundary.tsx new file mode 100644 index 00000000000..119a5fec997 --- /dev/null +++ b/app/javascript/packages/document-capture/components/suspense-error-boundary.tsx @@ -0,0 +1,62 @@ +import { Component, Suspense } from 'react'; +import type { ReactNode } from 'react'; + +interface SuspenseErrorBoundaryProps { + /** + * Fallback to show while suspense pending. + */ + fallback: NonNullable | null; + /** + * Error callback. + */ + onError: (error: Error) => void; + /** + * Error instance caught to allow for acknowledgment of rerender, in order to prevent infinite rerendering. + */ + handledError?: Error; + /** + * Suspense child. + */ + children: ReactNode; +} + +interface SuspenseErrorBoundaryState { + hasError: boolean; + error?: Error; +} + +class SuspenseErrorBoundary extends Component< + SuspenseErrorBoundaryProps, + SuspenseErrorBoundaryState +> { + constructor(props) { + super(props); + + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error) { + return { + hasError: true, + error, + }; + } + + componentDidCatch(error) { + const { onError } = this.props; + onError(error); + } + + render() { + const { fallback, children, handledError } = this.props; + const { hasError, error } = this.state; + + if (hasError && error !== handledError) { + return null; + } + + return {children}; + } +} + +export default SuspenseErrorBoundary; diff --git a/app/javascript/packages/password-strength/README.md b/app/javascript/packages/password-strength/README.md new file mode 100644 index 00000000000..7ded5901d8f --- /dev/null +++ b/app/javascript/packages/password-strength/README.md @@ -0,0 +1,32 @@ +# `@18f/password-strength` + +Custom element implementation that displays a strength meter and feedback for an associated password input element. + +## Usage + +Importing the element will register the `` custom element: + +```ts +import '@18f/password-strength/password-strength-element'; +``` + +The custom element will implement interactive behaviors, but all markup must already exist. + +```html + +``` diff --git a/app/javascript/packages/password-strength/package.json b/app/javascript/packages/password-strength/package.json new file mode 100644 index 00000000000..0b364ce0299 --- /dev/null +++ b/app/javascript/packages/password-strength/package.json @@ -0,0 +1,8 @@ +{ + "name": "@18f/identity-password-strength", + "private": true, + "version": "1.0.0", + "dependencies": { + "zxcvbn": "^4.4.2" + } +} diff --git a/app/javascript/packages/password-strength/password-strength-element.spec.ts b/app/javascript/packages/password-strength/password-strength-element.spec.ts new file mode 100644 index 00000000000..3183b3be64b --- /dev/null +++ b/app/javascript/packages/password-strength/password-strength-element.spec.ts @@ -0,0 +1,125 @@ +import { screen } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import './password-strength-element'; + +describe('PasswordStrengthElement', () => { + function createElement() { + document.body.innerHTML = ` + + + `; + + return document.querySelector('lg-password-strength')!; + } + + it('is shown when a value is entered', async () => { + const element = createElement(); + + const input = screen.getByRole('textbox'); + await userEvent.type(input, 'p'); + + expect(element.classList.contains('display-none')).to.be.false(); + }); + + it('is hidden when a value is removed', async () => { + const element = createElement(); + + const input = screen.getByRole('textbox'); + await userEvent.type(input, 'p'); + await userEvent.clear(input); + + expect(element.classList.contains('display-none')).to.be.true(); + }); + + it('displays strength and feedback for a given password', async () => { + const element = createElement(); + + const input = screen.getByRole('textbox'); + await userEvent.type(input, 'p'); + + expect(element.getAttribute('score')).to.equal('0'); + expect(screen.getByText('instructions.password.strength.0')).to.exist(); + expect( + screen.getByText('zxcvbn.feedback.add_another_word_or_two_uncommon_words_are_better'), + ).to.exist(); + }); + + it('invalidates input when value is not strong enough', async () => { + createElement(); + + const input: HTMLInputElement = screen.getByRole('textbox'); + await userEvent.type(input, 'p'); + + expect(input.validity.valid).to.be.false(); + }); + + it('shows custom feedback for forbidden password', async () => { + const element = createElement(); + + const input: HTMLInputElement = screen.getByRole('textbox'); + await userEvent.type(input, 'password'); + + expect(element.getAttribute('score')).to.equal('0'); + expect(screen.getByText('instructions.password.strength.0')).to.exist(); + expect( + screen.getByText('errors.attributes.password.avoid_using_phrases_that_are_easily_guessed'), + ).to.exist(); + expect(input.validity.valid).to.be.false(); + }); + + it('shows concatenated suggestions from zxcvbn if there is no specific warning', async () => { + createElement(); + + const input: HTMLInputElement = screen.getByRole('textbox'); + await userEvent.type(input, 'PASSWORD'); + + expect( + screen.getByText( + 'zxcvbn.feedback.add_another_word_or_two_uncommon_words_are_better. ' + + 'zxcvbn.feedback.all_uppercase_is_almost_as_easy_to_guess_as_all_lowercase', + ), + ).to.exist(); + expect(input.validity.valid).to.be.false(); + }); + + it('shows feedback for a password that satisfies zxcvbn but is too short', async () => { + const element = createElement(); + + const input: HTMLInputElement = screen.getByRole('textbox'); + await userEvent.type(input, 'mRd@fX!f&G'); + + expect(element.getAttribute('score')).to.equal('2'); + expect(screen.getByText('instructions.password.strength.2')).to.exist(); + expect(screen.getByText('errors.attributes.password.too_short.other')).to.exist(); + expect(input.validity.valid).to.be.false(); + }); + + it('shows feedback for a password that is valid', async () => { + const element = createElement(); + + const input: HTMLInputElement = screen.getByRole('textbox'); + await userEvent.type(input, 'mRd@fX!f&G?_*'); + + expect(element.getAttribute('score')).to.equal('4'); + expect(screen.getByText('instructions.password.strength.4')).to.exist(); + expect( + element.querySelector('.password-strength__feedback')!.textContent!.trim(), + ).to.be.empty(); + expect(input.validity.valid).to.be.true(); + }); +}); diff --git a/app/javascript/packages/password-strength/password-strength-element.ts b/app/javascript/packages/password-strength/password-strength-element.ts new file mode 100644 index 00000000000..c8a0735e71e --- /dev/null +++ b/app/javascript/packages/password-strength/password-strength-element.ts @@ -0,0 +1,182 @@ +import zxcvbn from 'zxcvbn'; +import { t } from '@18f/identity-i18n'; +import type { ZXCVBNResult, ZXCVBNScore } from 'zxcvbn'; + +const MINIMUM_STRENGTH: ZXCVBNScore = 3; + +const snakeCase = (string: string): string => + string.replace(/[ -]/g, '_').replace(/\W/g, '').toLowerCase(); + +class PasswordStrengthElement extends HTMLElement { + connectedCallback() { + this.input.addEventListener('input', () => this.#handleValueChange()); + } + + get strength(): HTMLElement { + return this.querySelector('.password-strength__strength')!; + } + + get feedback(): HTMLElement { + return this.querySelector('.password-strength__feedback')!; + } + + get input(): HTMLInputElement { + return this.ownerDocument.getElementById(this.getAttribute('input-id')!) as HTMLInputElement; + } + + get minimumLength(): number { + return Number(this.getAttribute('minimum-length')!); + } + + get forbiddenPasswords(): string[] { + return JSON.parse(this.getAttribute('forbidden-passwords')!); + } + + /** + * Returns a normalized score on zxcvbn's scale. Notably, this artificially lowers a score if it + * does not meet the minimum length requires, to avoid confusion where an invalid value would + * display as being a great password. + * + * @param result zxcvbn result + * + * @return Normalized zxcvbn score + */ + #getNormalizedScore(result: ZXCVBNResult): ZXCVBNScore { + const { score } = result; + + if (score >= MINIMUM_STRENGTH && this.input.value.length < this.minimumLength) { + return Math.max(MINIMUM_STRENGTH - 1, 0) as ZXCVBNScore; + } + + return score; + } + + /** + * Returns true if the input's value is considered valid for submission, or false otherwise. + * + * @param result zxcvbn result + * + * @return Whether the input's value is valid for submission + */ + #isValid(result: ZXCVBNResult): boolean { + return result.score >= MINIMUM_STRENGTH && this.input.value.length >= this.minimumLength; + } + + /** + * Given a zxcvbn default feedback string hardcoded in English, returns a localized equivalent + * string translated to the current language. + * + * @param englishFeedback Default feedback string from zxcvbn + * + * @return Localized equivalent string translated to the current language + */ + #getLocalizedFeedback(englishFeedback: string): string { + // i18n-tasks-use t('zxcvbn.feedback.a_word_by_itself_is_easy_to_guess') + // i18n-tasks-use t('zxcvbn.feedback.add_another_word_or_two_uncommon_words_are_better') + // i18n-tasks-use t('zxcvbn.feedback.all_uppercase_is_almost_as_easy_to_guess_as_all_lowercase') + // i18n-tasks-use t('zxcvbn.feedback.avoid_dates_and_years_that_are_associated_with_you') + // i18n-tasks-use t('zxcvbn.feedback.avoid_recent_years') + // i18n-tasks-use t('zxcvbn.feedback.avoid_repeated_words_and_characters') + // i18n-tasks-use t('zxcvbn.feedback.avoid_sequences') + // i18n-tasks-use t('zxcvbn.feedback.avoid_years_that_are_associated_with_you') + // i18n-tasks-use t('zxcvbn.feedback.capitalization_doesnt_help_very_much') + // i18n-tasks-use t('zxcvbn.feedback.common_names_and_surnames_are_easy_to_guess') + // i18n-tasks-use t('zxcvbn.feedback.dates_are_often_easy_to_guess') + // i18n-tasks-use t('zxcvbn.feedback.for_a_stronger_password_use_a_few_words_separated_by_spaces_but_avoid_common_phrases') + // i18n-tasks-use t('zxcvbn.feedback.names_and_surnames_by_themselves_are_easy_to_guess') + // i18n-tasks-use t('zxcvbn.feedback.no_need_for_symbols_digits_or_uppercase_letters') + // i18n-tasks-use t('zxcvbn.feedback.predictable_substitutions_like__instead_of_a_dont_help_very_much') + // i18n-tasks-use t('zxcvbn.feedback.recent_years_are_easy_to_guess') + // i18n-tasks-use t('zxcvbn.feedback.repeats_like_aaa_are_easy_to_guess') + // i18n-tasks-use t('zxcvbn.feedback.repeats_like_abcabcabc_are_only_slightly_harder_to_guess_than_abc') + // i18n-tasks-use t('zxcvbn.feedback.reversed_words_arent_much_harder_to_guess') + // i18n-tasks-use t('zxcvbn.feedback.sequences_like_abc_or_6543_are_easy_to_guess') + // i18n-tasks-use t('zxcvbn.feedback.short_keyboard_patterns_are_easy_to_guess') + // i18n-tasks-use t('zxcvbn.feedback.straight_rows_of_keys_are_easy_to_guess') + // i18n-tasks-use t('zxcvbn.feedback.there_is_no_need_for_symbols_digits_or_uppercase_letters') + // i18n-tasks-use t('zxcvbn.feedback.this_is_a_top_100_common_password') + // i18n-tasks-use t('zxcvbn.feedback.this_is_a_top_10_common_password') + // i18n-tasks-use t('zxcvbn.feedback.this_is_a_very_common_password') + // i18n-tasks-use t('zxcvbn.feedback.this_is_similar_to_a_commonly_used_password') + // i18n-tasks-use t('zxcvbn.feedback.use_a_few_words_avoid_common_phrases') + // i18n-tasks-use t('zxcvbn.feedback.use_a_longer_keyboard_pattern_with_more_turns') + return t(`zxcvbn.feedback.${snakeCase(englishFeedback)}`); + } + + /** + * Returns text to be shown as feedback for the current input value, based on the zxcvbn result + * and other factors such as minimum password length or use of a forbidden password. + * + * @param result zxcvbn result + * + * @return Localized feedback text + */ + #getNormalizedFeedback(result: ZXCVBNResult): string | null { + const { warning, suggestions } = result.feedback; + + if (this.forbiddenPasswords.includes(this.input.value)) { + return t('errors.attributes.password.avoid_using_phrases_that_are_easily_guessed'); + } + + if (warning) { + return this.#getLocalizedFeedback(warning); + } + + if (suggestions.length) { + return suggestions.map((suggestion) => this.#getLocalizedFeedback(suggestion)).join('. '); + } + + if (this.input.value.length < this.minimumLength) { + return t('errors.attributes.password.too_short.other', { count: this.minimumLength }); + } + + return null; + } + + /** + * Returns the strength label associated with a given score. + * + * @param score Score + * + * @return Strength label. + */ + #getStrengthLabel(score: number): string { + // i18n-tasks-use t('instructions.password.strength.0') + // i18n-tasks-use t('instructions.password.strength.1') + // i18n-tasks-use t('instructions.password.strength.2') + // i18n-tasks-use t('instructions.password.strength.3') + // i18n-tasks-use t('instructions.password.strength.4') + return t(`instructions.password.strength.${score}`); + } + + /** + * Updates the current strength and feedback indicators in response to a changed input value. + */ + #handleValueChange() { + const hasValue = !!this.input.value; + this.classList.toggle('display-none', !hasValue); + this.removeAttribute('score'); + if (hasValue) { + const result = zxcvbn(this.input.value, this.forbiddenPasswords); + const score = this.#getNormalizedScore(result); + this.setAttribute('score', String(score)); + this.input.setCustomValidity( + this.#isValid(result) ? '' : t('errors.messages.stronger_password'), + ); + this.strength.textContent = this.#getStrengthLabel(score); + this.feedback.textContent = this.#getNormalizedFeedback(result); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'lg-password-strength': PasswordStrengthElement; + } +} + +if (!customElements.get('lg-password-strength')) { + customElements.define('lg-password-strength', PasswordStrengthElement); +} + +export default PasswordStrengthElement; diff --git a/app/javascript/packages/phone-input/package.json b/app/javascript/packages/phone-input/package.json index b1fc9c8cc45..b360a4f2f6a 100644 --- a/app/javascript/packages/phone-input/package.json +++ b/app/javascript/packages/phone-input/package.json @@ -4,6 +4,6 @@ "version": "1.0.0", "dependencies": { "intl-tel-input": "^17.0.19", - "libphonenumber-js": "^1.10.53" + "libphonenumber-js": "^1.10.54" } } diff --git a/app/javascript/packs/pw-strength.js b/app/javascript/packs/pw-strength.js deleted file mode 100644 index 54143a7ce8c..00000000000 --- a/app/javascript/packs/pw-strength.js +++ /dev/null @@ -1,144 +0,0 @@ -import zxcvbn from 'zxcvbn'; -import { t } from '@18f/identity-i18n'; - -// zxcvbn returns a strength score from 0 to 4 -// we map those scores to: -// 1. a CSS class to the pw strength module -// 2. text describing the score -const scale = { - 0: ['pw-very-weak', t('instructions.password.strength.i')], - 1: ['pw-weak', t('instructions.password.strength.ii')], - 2: ['pw-average', t('instructions.password.strength.iii')], - 3: ['pw-good', t('instructions.password.strength.iv')], - 4: ['pw-great', t('instructions.password.strength.v')], -}; - -const snakeCase = (string) => string.replace(/[ -]/g, '_').replace(/\W/g, '').toLowerCase(); - -// fallback if zxcvbn lookup fails / field is empty -const fallback = ['pw-na', '...']; - -function getStrength(z) { - // override the strength value to 2 if the password is < 12 - if (z.password.length < 12 && z.score >= 3) { - z.score = 2; - } - return z && z.password.length ? scale[z.score] : fallback; -} - -export function getFeedback(z, minPasswordLength, forbiddenPasswords) { - if (!z || !z.password) { - return ' '; - } - - const { warning, suggestions } = z.feedback; - - if (forbiddenPasswords.includes(z.password)) { - return t('errors.attributes.password.avoid_using_phrases_that_are_easily_guessed'); - } - - if (!warning && !suggestions.length) { - if (z.password.length < minPasswordLength) { - return t('errors.attributes.password.too_short.other', { count: minPasswordLength }); - } - - return ' '; - } - - function lookup(str) { - // i18n-tasks-use t('zxcvbn.feedback.a_word_by_itself_is_easy_to_guess') - // i18n-tasks-use t('zxcvbn.feedback.add_another_word_or_two_uncommon_words_are_better') - // i18n-tasks-use t('zxcvbn.feedback.all_uppercase_is_almost_as_easy_to_guess_as_all_lowercase') - // i18n-tasks-use t('zxcvbn.feedback.avoid_dates_and_years_that_are_associated_with_you') - // i18n-tasks-use t('zxcvbn.feedback.avoid_recent_years') - // i18n-tasks-use t('zxcvbn.feedback.avoid_repeated_words_and_characters') - // i18n-tasks-use t('zxcvbn.feedback.avoid_sequences') - // i18n-tasks-use t('zxcvbn.feedback.avoid_years_that_are_associated_with_you') - // i18n-tasks-use t('zxcvbn.feedback.capitalization_doesnt_help_very_much') - // i18n-tasks-use t('zxcvbn.feedback.common_names_and_surnames_are_easy_to_guess') - // i18n-tasks-use t('zxcvbn.feedback.dates_are_often_easy_to_guess') - // i18n-tasks-use t('zxcvbn.feedback.for_a_stronger_password_use_a_few_words_separated_by_spaces_but_avoid_common_phrases') - // i18n-tasks-use t('zxcvbn.feedback.names_and_surnames_by_themselves_are_easy_to_guess') - // i18n-tasks-use t('zxcvbn.feedback.no_need_for_symbols_digits_or_uppercase_letters') - // i18n-tasks-use t('zxcvbn.feedback.predictable_substitutions_like__instead_of_a_dont_help_very_much') - // i18n-tasks-use t('zxcvbn.feedback.recent_years_are_easy_to_guess') - // i18n-tasks-use t('zxcvbn.feedback.repeats_like_aaa_are_easy_to_guess') - // i18n-tasks-use t('zxcvbn.feedback.repeats_like_abcabcabc_are_only_slightly_harder_to_guess_than_abc') - // i18n-tasks-use t('zxcvbn.feedback.reversed_words_arent_much_harder_to_guess') - // i18n-tasks-use t('zxcvbn.feedback.sequences_like_abc_or_6543_are_easy_to_guess') - // i18n-tasks-use t('zxcvbn.feedback.short_keyboard_patterns_are_easy_to_guess') - // i18n-tasks-use t('zxcvbn.feedback.straight_rows_of_keys_are_easy_to_guess') - // i18n-tasks-use t('zxcvbn.feedback.there_is_no_need_for_symbols_digits_or_uppercase_letters') - // i18n-tasks-use t('zxcvbn.feedback.this_is_a_top_100_common_password') - // i18n-tasks-use t('zxcvbn.feedback.this_is_a_top_10_common_password') - // i18n-tasks-use t('zxcvbn.feedback.this_is_a_very_common_password') - // i18n-tasks-use t('zxcvbn.feedback.this_is_similar_to_a_commonly_used_password') - // i18n-tasks-use t('zxcvbn.feedback.use_a_few_words_avoid_common_phrases') - // i18n-tasks-use t('zxcvbn.feedback.use_a_longer_keyboard_pattern_with_more_turns') - return t(`zxcvbn.feedback.${snakeCase(str)}`); - } - - if (warning) { - return lookup(warning); - } - - return `${suggestions.map((s) => lookup(s)).join('. ')}`; -} - -/** - * @param {HTMLElement?} element - * - * @return {string[]} - */ -export function getForbiddenPasswords(element) { - try { - return JSON.parse(element.dataset.forbidden); - } catch { - return []; - } -} - -function updatePasswordFeedback(cls, strength, feedback) { - const pwCntnr = document.getElementById('pw-strength-cntnr'); - const pwStrength = document.getElementById('pw-strength-txt'); - const pwFeedback = document.getElementById('pw-strength-feedback'); - - pwCntnr.className = cls; - pwStrength.innerHTML = strength; - pwFeedback.innerHTML = feedback; -} - -function validatePasswordField(score, input) { - if (score < 3) { - input.setCustomValidity(t('errors.messages.stronger_password')); - } else { - input.setCustomValidity(''); - } -} - -function checkPasswordStrength(password, minPasswordLength, forbiddenPasswords, input) { - const z = zxcvbn(password, forbiddenPasswords); - const [cls, strength] = getStrength(z); - const feedback = getFeedback(z, minPasswordLength, forbiddenPasswords); - - validatePasswordField(z.score, input); - updatePasswordFeedback(cls, strength, feedback); -} - -function analyzePw() { - const input = - document.querySelector('.password-toggle__input') || - document.querySelector('.password-confirmation__input'); - const forbiddenPasswordsElement = document.querySelector('[data-forbidden]'); - const forbiddenPasswords = getForbiddenPasswords(forbiddenPasswordsElement); - const minPasswordLength = document - .getElementById('pw-strength-cntnr') - .getAttribute('data-pw-min-length'); - - input.addEventListener('input', (e) => { - const password = e.target.value; - checkPasswordStrength(password, minPasswordLength, forbiddenPasswords, input); - }); -} - -document.addEventListener('DOMContentLoaded', analyzePw); diff --git a/app/models/service_provider.rb b/app/models/service_provider.rb index af1454ff779..fa1ba8cb174 100644 --- a/app/models/service_provider.rb +++ b/app/models/service_provider.rb @@ -27,7 +27,11 @@ class ServiceProvider < ApplicationRecord scope(:active, -> { where(active: true) }) scope( :with_push_notification_urls, - -> { where.not(push_notification_url: nil).where.not(push_notification_url: '') }, + -> { + where.not(push_notification_url: nil). + where.not(push_notification_url: ''). + where(active: true) + }, ) IAA_INTERNAL = 'LGINTERNAL' diff --git a/app/presenters/two_factor_authentication/webauthn_edit_presenter.rb b/app/presenters/two_factor_authentication/webauthn_edit_presenter.rb new file mode 100644 index 00000000000..f9baa4cb0c5 --- /dev/null +++ b/app/presenters/two_factor_authentication/webauthn_edit_presenter.rb @@ -0,0 +1,61 @@ +module TwoFactorAuthentication + class WebauthnEditPresenter + include ActionView::Helpers::TranslationHelper + + attr_reader :configuration + + delegate :platform_authenticator?, to: :configuration + + def initialize(configuration:) + @configuration = configuration + end + + def heading + if platform_authenticator? + t('two_factor_authentication.webauthn_platform.edit_heading') + else + t('two_factor_authentication.webauthn_roaming.edit_heading') + end + end + + def nickname_field_label + if platform_authenticator? + t('two_factor_authentication.webauthn_platform.nickname') + else + t('two_factor_authentication.webauthn_roaming.nickname') + end + end + + def rename_button_label + if platform_authenticator? + t('two_factor_authentication.webauthn_platform.change_nickname') + else + t('two_factor_authentication.webauthn_roaming.change_nickname') + end + end + + def delete_button_label + if platform_authenticator? + t('two_factor_authentication.webauthn_platform.delete') + else + t('two_factor_authentication.webauthn_roaming.delete') + end + end + + def rename_success_alert_text + if platform_authenticator? + t('two_factor_authentication.webauthn_platform.renamed') + else + t('two_factor_authentication.webauthn_roaming.renamed') + end + end + + def delete_success_alert_text + if platform_authenticator? + t('two_factor_authentication.webauthn_platform.deleted') + else + t('two_factor_authentication.webauthn_roaming.deleted') + end + end + end +end diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 223af635040..e775a18ab07 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -4802,22 +4802,25 @@ def vendor_outage( ) end - # @param [Boolean] success - # @param [Hash] error_details - # @param [Integer] configuration_id + # @param [Boolean] success Whether the submission was successful + # @param [Integer] configuration_id Database ID for the configuration + # @param [Boolean] platform_authenticator Whether the configuration was a platform authenticator + # @param [Hash] error_details Details for error that occurred in unsuccessful submission # Tracks when user attempts to delete a WebAuthn configuration # @identity.idp.previous_event_name WebAuthn Deleted def webauthn_delete_submitted( success:, configuration_id:, + platform_authenticator:, error_details: nil, **extra ) track_event( :webauthn_delete_submitted, success:, - error_details:, configuration_id:, + platform_authenticator:, + error_details:, **extra, ) end @@ -4848,21 +4851,24 @@ def webauthn_setup_visit(platform_authenticator:, enabled_mfa_methods_count:, ** ) end - # @param [Boolean] success - # @param [Hash] error_details - # @param [Integer] configuration_id + # @param [Boolean] success Whether the submission was successful + # @param [Integer] configuration_id Database ID for the configuration + # @param [Boolean] platform_authenticator Whether the configuration was a platform authenticator + # @param [Hash] error_details Details for error that occurred in unsuccessful submission # Tracks when user submits a name change for a WebAuthn configuration def webauthn_update_name_submitted( success:, configuration_id:, + platform_authenticator:, error_details: nil, **extra ) track_event( :webauthn_update_name_submitted, success:, - error_details:, + platform_authenticator:, configuration_id:, + error_details:, **extra, ) end diff --git a/app/services/saml_request_validator.rb b/app/services/saml_request_validator.rb index 21ddd0ea7db..c54666b6cb1 100644 --- a/app/services/saml_request_validator.rb +++ b/app/services/saml_request_validator.rb @@ -1,10 +1,15 @@ class SamlRequestValidator include ActiveModel::Model + validate :cert_exists validate :authorized_service_provider validate :authorized_authn_context validate :authorized_email_nameid_format + def initialize(blank_cert: false) + @blank_cert = blank_cert + end + def call(service_provider:, authn_context:, nameid_format:, authn_context_comparison: nil) self.service_provider = service_provider self.authn_context = Array(authn_context) @@ -46,6 +51,12 @@ def authorized_authn_context end end + def cert_exists + if @blank_cert + errors.add(:service_provider, :blank_cert_element_req, type: :blank_cert_element_req) + end + end + def valid_authn_context? valid_contexts = Saml::Idp::Constants::VALID_AUTHN_CONTEXTS.dup valid_contexts += Saml::Idp::Constants::PASSWORD_AUTHN_CONTEXT_CLASSREFS if step_up_comparison? diff --git a/app/views/accounts/_webauthn_roaming.html.erb b/app/views/accounts/_webauthn_roaming.html.erb index e50316bbce8..8672240e73a 100644 --- a/app/views/accounts/_webauthn_roaming.html.erb +++ b/app/views/accounts/_webauthn_roaming.html.erb @@ -2,21 +2,20 @@ <%= t('account.index.webauthn') %> -
- <% MfaContext.new(current_user).webauthn_roaming_configurations.each do |cfg| %> -
-
- <%= cfg.name %> -
- <% if MfaPolicy.new(current_user).multiple_factors_enabled? %> -
- <%= link_to( - t('account.index.webauthn_delete'), - webauthn_setup_delete_path(id: cfg.id), - ) %> -
- <% end %> -
+
+ <% MfaContext.new(current_user).webauthn_roaming_configurations.each do |configuration| %> + <%= render ManageableAuthenticatorComponent.new( + configuration:, + user_session:, + manage_url: edit_webauthn_path(id: configuration.id), + manage_api_url: api_internal_two_factor_authentication_webauthn_path(id: configuration.id), + custom_strings: { + deleted: t('two_factor_authentication.webauthn_roaming.deleted'), + renamed: t('two_factor_authentication.webauthn_roaming.renamed'), + manage_accessible_label: t('two_factor_authentication.webauthn_roaming.manage_accessible_label'), + }, + role: 'list-item', + ) %> <% end %>
@@ -25,5 +24,6 @@ link_to(webauthn_setup_path, **tag_options, &block) end, icon: :add, - class: 'usa-button usa-button--outline margin-top-2', + outline: true, + class: 'margin-top-2', ).with_content(t('account.index.webauthn_add')) %> diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb index 2d17b9e9020..508af264d4b 100644 --- a/app/views/devise/passwords/edit.html.erb +++ b/app/views/devise/passwords/edit.html.erb @@ -17,16 +17,14 @@ <%= render PasswordConfirmationComponent.new( form: f, password_label: t('forms.passwords.edit.labels.password'), + forbidden_passwords: @forbidden_passwords, field_options: { input_html: { aria: { describedby: 'password-description' }, }, }, ) %> - <%= render 'devise/shared/password_strength', forbidden_passwords: @forbidden_passwords %> <%= f.submit t('forms.passwords.edit.buttons.submit'), class: 'display-block margin-y-5' %> <% end %> <%= render 'shared/password_accordion' %> - -<%= javascript_packs_tag_once 'pw-strength' %> diff --git a/app/views/devise/shared/_password_strength.html.erb b/app/views/devise/shared/_password_strength.html.erb deleted file mode 100644 index f1e3d3a77a5..00000000000 --- a/app/views/devise/shared/_password_strength.html.erb +++ /dev/null @@ -1,21 +0,0 @@ - diff --git a/app/views/event_disavowal/new.html.erb b/app/views/event_disavowal/new.html.erb index 137f751e5fa..38412a44d6b 100644 --- a/app/views/event_disavowal/new.html.erb +++ b/app/views/event_disavowal/new.html.erb @@ -16,14 +16,16 @@ label: t('forms.passwords.edit.labels.password'), required: true, input_html: { + id: 'new-password', autocomplete: 'new-password', }, }, ) %> - <%= render 'devise/shared/password_strength', forbidden_passwords: @forbidden_passwords %> + <%= render PasswordStrengthComponent.new( + input_id: 'new-password', + forbidden_passwords: @forbidden_passwords, + ) %> <%= f.submit t('forms.passwords.edit.buttons.submit'), class: 'margin-bottom-4' %> <% end %> <%= render 'shared/password_accordion' %> - -<%= javascript_packs_tag_once 'pw-strength' %> diff --git a/app/views/sign_up/passwords/new.html.erb b/app/views/sign_up/passwords/new.html.erb index 70b18b831c8..f0d3a80144b 100644 --- a/app/views/sign_up/passwords/new.html.erb +++ b/app/views/sign_up/passwords/new.html.erb @@ -14,11 +14,11 @@ <%= text_field_tag('username', @email_address.email, hidden: true, autocomplete: 'username') %> <%= render PasswordConfirmationComponent.new( form: f, + forbidden_passwords: @forbidden_passwords, field_options: { input_html: { aria: { describedby: 'password-description' } }, }, ) %> - <%= render 'devise/shared/password_strength', forbidden_passwords: @forbidden_passwords %> <%= hidden_field_tag :confirmation_token, @confirmation_token, id: 'confirmation_token' %> <%= f.submit t('forms.buttons.continue'), class: 'display-block margin-y-5' %> <% end %> @@ -26,5 +26,3 @@ <%= render 'shared/password_accordion' %> <%= render 'shared/cancel' %> - -<%= javascript_packs_tag_once 'pw-strength' %> diff --git a/app/views/users/passwords/edit.html.erb b/app/views/users/passwords/edit.html.erb index fd08abe5f5b..45a479f38f1 100644 --- a/app/views/users/passwords/edit.html.erb +++ b/app/views/users/passwords/edit.html.erb @@ -14,18 +14,16 @@ <%= render PasswordConfirmationComponent.new( form: f, password_label: t('forms.passwords.edit.labels.password'), + forbidden_passwords: @forbidden_passwords, field_options: { input_html: { aria: { describedby: 'password-description' }, }, }, ) %> - <%= render 'devise/shared/password_strength', forbidden_passwords: @forbidden_passwords %> <%= f.submit t('forms.buttons.submit.update'), class: 'display-block margin-top-5 margin-bottom-4' %> <% end %> <%= render 'shared/password_accordion' %> <%= render 'shared/cancel', link: account_path %> - -<%= javascript_packs_tag_once 'pw-strength' %> diff --git a/app/views/users/webauthn/edit.html.erb b/app/views/users/webauthn/edit.html.erb index c8be25675b3..5dc61948680 100644 --- a/app/views/users/webauthn/edit.html.erb +++ b/app/views/users/webauthn/edit.html.erb @@ -1,6 +1,6 @@ -<% self.title = t('two_factor_authentication.webauthn_platform.edit_heading') %> +<% self.title = @presenter.heading %> -<%= render PageHeadingComponent.new.with_content(t('two_factor_authentication.webauthn_platform.edit_heading')) %> +<%= render PageHeadingComponent.new.with_content(@presenter.heading) %> <%= simple_form_for( @form, @@ -12,20 +12,17 @@ <%= render ValidatedFieldComponent.new( form: f, name: :name, - label: t('two_factor_authentication.webauthn_platform.nickname'), + label: @presenter.nickname_field_label, ) %> - <%= f.submit( - t('two_factor_authentication.webauthn_platform.change_nickname'), - class: 'display-block margin-top-5', - ) %> + <%= f.submit(@presenter.rename_button_label, class: 'display-block margin-top-5') %> <% end %> <%= render ButtonComponent.new( action: ->(**tag_options, &block) do button_to( webauthn_path(id: @form.configuration.id), - form: { aria: { label: t('two_factor_authentication.webauthn_platform.delete') } }, + form: { aria: { label: @presenter.delete_button_label } }, **tag_options, &block ) @@ -35,6 +32,6 @@ wide: true, danger: true, class: 'display-block margin-top-2', - ).with_content(t('two_factor_authentication.webauthn_platform.delete')) %> + ).with_content(@presenter.delete_button_label) %> <%= render 'shared/cancel', link: account_path %> diff --git a/config/locales/account/en.yml b/config/locales/account/en.yml index e34c1204fbf..8c51de05e9a 100644 --- a/config/locales/account/en.yml +++ b/config/locales/account/en.yml @@ -55,7 +55,6 @@ en: webauthn: Security key webauthn_add: Add security key webauthn_confirm_delete: Yes, remove key - webauthn_delete: Remove key webauthn_platform: Face or touch unlock webauthn_platform_add: Add face or touch unlock webauthn_platform_confirm_delete: Yes, remove face or touch unlock diff --git a/config/locales/account/es.yml b/config/locales/account/es.yml index 9e72845a8cf..daf1a7c82bf 100644 --- a/config/locales/account/es.yml +++ b/config/locales/account/es.yml @@ -56,7 +56,6 @@ es: webauthn: Clave de seguridad webauthn_add: Añadir clave de seguridad webauthn_confirm_delete: Si quitar la llave - webauthn_delete: Quitar llave webauthn_platform: El desbloqueo facial o táctil webauthn_platform_add: Añadir el desbloqueo facial o táctil webauthn_platform_confirm_delete: Si, quitar el desbloqueo facial o táctil diff --git a/config/locales/account/fr.yml b/config/locales/account/fr.yml index 445776e7d5a..6fb436a6fb8 100644 --- a/config/locales/account/fr.yml +++ b/config/locales/account/fr.yml @@ -59,7 +59,6 @@ fr: webauthn: Clé de sécurité webauthn_add: Ajouter une clé de sécurité webauthn_confirm_delete: Oui, supprimer la clé - webauthn_delete: Supprimer la clé webauthn_platform: Le déverouillage facial ou déverrouillage par empreinte digitale webauthn_platform_add: Ajouter le déverouillage facial ou déverrouillage par empreinte digitale webauthn_platform_confirm_delete: Oui, supprimer le déverouillage facial ou diff --git a/config/locales/errors/en.yml b/config/locales/errors/en.yml index e3861954239..e00fab81232 100644 --- a/config/locales/errors/en.yml +++ b/config/locales/errors/en.yml @@ -50,6 +50,7 @@ en: messages: already_confirmed: was already confirmed, please try signing in blank: Please fill in this field. + blank_cert_element_req: We cannot detect a certificate in your request. confirmation_code_incorrect: Incorrect verification code. Did you type it in correctly? confirmation_invalid_token: Invalid confirmation link. Either the link expired or you already confirmed your account. diff --git a/config/locales/errors/es.yml b/config/locales/errors/es.yml index be20e24d5d1..970491697c4 100644 --- a/config/locales/errors/es.yml +++ b/config/locales/errors/es.yml @@ -54,6 +54,7 @@ es: messages: already_confirmed: ya estaba confirmado, por favor intente iniciar una sesión blank: Por favor, rellenar este campo. + blank_cert_element_req: No podemos detectar un certificado en su solicitud. confirmation_code_incorrect: Código de verificación incorrecto. ¿Lo escribió correctamente? confirmation_invalid_token: El enlace de confirmación no es válido. El enlace expiró o usted ya ha confirmado su cuenta. diff --git a/config/locales/errors/fr.yml b/config/locales/errors/fr.yml index fb52095e419..a62e9a84798 100644 --- a/config/locales/errors/fr.yml +++ b/config/locales/errors/fr.yml @@ -59,6 +59,7 @@ fr: messages: already_confirmed: a déjà été confirmé, veuillez essayer de vous connecter blank: Veuillez remplir ce champ. + blank_cert_element_req: Nous ne pouvons pas détecter un certificat sur votre demande. confirmation_code_incorrect: Code de vérification incorrect. L’avez-vous saisi correctement ? confirmation_invalid_token: Lien de confirmation non valide. Le lien est expiré ou vous avez déjà confirmé votre compte. diff --git a/config/locales/instructions/en.yml b/config/locales/instructions/en.yml index 0783d62a70e..63edaca651b 100644 --- a/config/locales/instructions/en.yml +++ b/config/locales/instructions/en.yml @@ -88,12 +88,12 @@ en: you verified your identity with this account. If you don’t have it, you can still reset your password and then reverify your identity. strength: - i: Very weak - ii: Weak - iii: Average + 0: Very weak + 1: Weak + 2: Average + 3: Good + 4: Great intro: 'Password strength: ' - iv: Good - v: Great sp_handoff_bounced: Your sign in was successful, but %{sp_name} sent you back to %{app_name}. Please contact %{sp_link} for help. sp_handoff_bounced_with_no_sp: your service provider diff --git a/config/locales/instructions/es.yml b/config/locales/instructions/es.yml index 52014ed2233..37a4532fb86 100644 --- a/config/locales/instructions/es.yml +++ b/config/locales/instructions/es.yml @@ -93,12 +93,12 @@ es: con ella, de todos modos puede restablecer su contraseña y luego volver a verificar su identidad. strength: - i: Muy débil - ii: Débil - iii: Promedio + 0: Muy débil + 1: Débil + 2: Promedio + 3: Buena + 4: 'Muy buena' intro: 'Seguridad de la contraseña:' - iv: Buena - v: 'Muy buena' sp_handoff_bounced: Su inicio de sesión fue exitoso, pero %{sp_name} lo envió de regreso a %{app_name}. Póngase en contacto con %{sp_link} para obtener ayuda. diff --git a/config/locales/instructions/fr.yml b/config/locales/instructions/fr.yml index 0fdb4d98fe4..ec2e74cfe48 100644 --- a/config/locales/instructions/fr.yml +++ b/config/locales/instructions/fr.yml @@ -105,12 +105,12 @@ fr: avec ce compte. Si vous ne l’avez pas, vous pouvez toujours réinitialiser votre mot de passe et ensuite revérifier votre identité. strength: - i: Très faible - ii: Faible - iii: Moyen + 0: Très faible + 1: Faible + 2: Moyen + 3: Bonne + 4: Excellente intro: 'Force du mot de passe : ' - iv: Bonne - v: Excellente sp_handoff_bounced: Votre connexion a réussi, mais %{sp_name} vous a renvoyé à %{app_name}. Veuillez contacter %{sp_link} pour obtenir de l’aide. sp_handoff_bounced_with_no_sp: votre fournisseur de service diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml index e55ae0021a5..f09a8862afb 100644 --- a/config/locales/two_factor_authentication/en.yml +++ b/config/locales/two_factor_authentication/en.yml @@ -208,4 +208,12 @@ en: renamed: Successfully renamed your face or touch unlock method webauthn_platform_header_text: Use face or touch unlock webauthn_platform_use_key: Use screen unlock + webauthn_roaming: + change_nickname: Change nickname + delete: Delete this device + deleted: Successfully deleted a security key method + edit_heading: Manage your security key settings + manage_accessible_label: Manage security key + nickname: Nickname + renamed: Successfully renamed your security key method webauthn_use_key: Use security key diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml index 5f094a6efae..ac4d95f3f8f 100644 --- a/config/locales/two_factor_authentication/es.yml +++ b/config/locales/two_factor_authentication/es.yml @@ -223,4 +223,12 @@ es: facial o táctil webauthn_platform_header_text: Usar desbloqueo facial o táctil webauthn_platform_use_key: Usar el desbloqueo de pantalla + webauthn_roaming: + change_nickname: Cambiar apodo + delete: Eliminar este dispositivo + deleted: Se ha eliminado correctamente un método de clave de seguridad + edit_heading: Gestionar la configuración de su clave de seguridad + manage_accessible_label: Gestionar la clave de seguridad + nickname: Apodo + renamed: Se ha cambiado correctamente el nombre de su método de clave de seguridad webauthn_use_key: Usar llave de seguridad diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml index 01465df3f19..af4a50414f1 100644 --- a/config/locales/two_factor_authentication/fr.yml +++ b/config/locales/two_factor_authentication/fr.yml @@ -235,4 +235,12 @@ fr: empreinte digitale a été renommée avec succès webauthn_platform_header_text: Utilisez le déverrouillage facial ou tactile webauthn_platform_use_key: Utiliser le déverrouillage de l’écran + webauthn_roaming: + change_nickname: Changer de pseudo + delete: Supprimer cet appareil + deleted: Suppression réussie d’une méthode de clé de sécurité + edit_heading: Gérer les paramètres de votre clé de sécurité + manage_accessible_label: Gérer la clé de sécurité + nickname: Pseudo + renamed: Votre méthode de clé de sécurité a été renommée avec succès webauthn_use_key: Utiliser la clé de sécurité diff --git a/dockerfiles/idp_review_app.Dockerfile b/dockerfiles/idp_review_app.Dockerfile index 839115cc1d1..01c5d97bb91 100644 --- a/dockerfiles/idp_review_app.Dockerfile +++ b/dockerfiles/idp_review_app.Dockerfile @@ -1,12 +1,6 @@ FROM ruby:3.3.0-slim # Set environment variables -ARG ARG_CI_ENVIRONMENT_SLUG="placeholder" -ARG ARG_CI_COMMIT_BRANCH="branch_placeholder" -ARG ARG_CI_COMMIT_SHA="sha_placeholder" -ENV CI_ENVIRONMENT_SLUG=${ARG_CI_ENVIRONMENT_SLUG} -ENV CI_COMMIT_BRANCH=${ARG_CI_COMMIT_BRANCH} -ENV CI_COMMIT_SHA=${ARG_CI_COMMIT_SHA} ENV RAILS_ROOT /app ENV RAILS_ENV production ENV NODE_ENV production @@ -36,29 +30,6 @@ ENV DOMAIN_NAME localhost:3000 ENV PIV_CAC_SERVICE_URL https://localhost:8443/ ENV PIV_CAC_VERIFY_TOKEN_URL https://localhost:8443/ -RUN echo Env Value : $CI_ENVIRONMENT_SLUG - -# Prevent documentation installation -RUN echo 'path-exclude=/usr/share/doc/*' > /etc/dpkg/dpkg.cfg.d/00_nodoc && \ - echo 'path-exclude=/usr/share/man/*' >> /etc/dpkg/dpkg.cfg.d/00_nodoc && \ - echo 'path-exclude=/usr/share/groff/*' >> /etc/dpkg/dpkg.cfg.d/00_nodoc && \ - echo 'path-exclude=/usr/share/info/*' >> /etc/dpkg/dpkg.cfg.d/00_nodoc && \ - echo 'path-exclude=/usr/share/lintian/*' >> /etc/dpkg/dpkg.cfg.d/00_nodoc && \ - echo 'path-exclude=/usr/share/linda/*' >> /etc/dpkg/dpkg.cfg.d/00_nodoc - -# Create a new user and set up the working directory -RUN addgroup --gid 1000 app && \ - adduser --uid 1000 --gid 1000 --disabled-password --gecos "" app && \ - mkdir -p $RAILS_ROOT && \ - mkdir -p $BUNDLE_PATH && \ - mkdir -p $RAILS_ROOT/tmp/pids && \ - chown -R app:app $RAILS_ROOT && \ - chown -R app:app $BUNDLE_PATH - -# Setup timezone data -ENV TZ=Etc/UTC -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - # Install dependencies RUN apt-get update && \ apt-get install -y \ @@ -90,6 +61,19 @@ RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /usr RUN echo "deb [signed-by=/usr/share/keyrings/yarn-archive-keyring.gpg] https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list RUN apt-get update && apt-get install -y yarn=1.22.5-1 +# Create a new user and set up the working directory +RUN addgroup --gid 1000 app && \ + adduser --uid 1000 --gid 1000 --disabled-password --gecos "" app && \ + mkdir -p $RAILS_ROOT && \ + mkdir -p $BUNDLE_PATH && \ + mkdir -p $RAILS_ROOT/tmp/pids && \ + chown -R app:app $RAILS_ROOT && \ + chown -R app:app $BUNDLE_PATH + +# Setup timezone data +ENV TZ=Etc/UTC +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + # Create the working directory WORKDIR $RAILS_ROOT @@ -128,9 +112,6 @@ COPY --chown=app:app ./babel.config.js ./babel.config.js COPY --chown=app:app ./webpack.config.js ./webpack.config.js COPY --chown=app:app ./.browserslistrc ./.browserslistrc -RUN mkdir -p $RAILS_ROOT/public/api/ -RUN echo "{\"branch\":\"$CI_COMMIT_BRANCH\",\"git_sha\":\"$CI_COMMIT_SHA\"}" > $RAILS_ROOT/public/api/deploy.json - # Copy keys COPY --chown=app:app keys.example $RAILS_ROOT/keys @@ -143,15 +124,6 @@ COPY --chown=app:app public/ban-robots.txt $RAILS_ROOT/public/robots.txt # Copy application.yml.default to application.yml COPY --chown=app:app ./config/application.yml.default.docker $RAILS_ROOT/config/application.yml -# Generate and place SSL certificates for puma -RUN openssl req -x509 -sha256 -nodes -newkey rsa:2048 -days 1825 \ - -keyout $RAILS_ROOT/keys/localhost.key \ - -out $RAILS_ROOT/keys/localhost.crt \ - -subj "/C=US/ST=Fake/L=Fakerton/O=Dis/CN=localhost" - -# Precompile assets -RUN bundle exec rake assets:precompile --trace - # Setup config files COPY --chown=app:app config/agencies.localdev.yml $RAILS_ROOT/config/agencies.yml COPY --chown=app:app config/iaa_gtcs.localdev.yml $RAILS_ROOT/config/iaa_gtcs.yml @@ -164,6 +136,20 @@ COPY --chown=app:app config/partner_accounts.localdev.yml $RAILS_ROOT/config/par COPY --chown=app:app certs.example $RAILS_ROOT/certs COPY --chown=app:app config/service_providers.localdev.yml $RAILS_ROOT/config/service_providers.yml +# Precompile assets +RUN bundle exec rake assets:precompile --trace + +ARG ARG_CI_COMMIT_BRANCH="branch_placeholder" +ARG ARG_CI_COMMIT_SHA="sha_placeholder" +RUN mkdir -p $RAILS_ROOT/public/api/ +RUN echo "{\"branch\":\"$ARG_CI_COMMIT_BRANCH\",\"git_sha\":\"$ARG_CI_COMMIT_SHA\"}" > $RAILS_ROOT/public/api/deploy.json + +# Generate and place SSL certificates for puma +RUN openssl req -x509 -sha256 -nodes -newkey rsa:2048 -days 1825 \ + -keyout $RAILS_ROOT/keys/localhost.key \ + -out $RAILS_ROOT/keys/localhost.crt \ + -subj "/C=US/ST=Fake/L=Fakerton/O=Dis/CN=localhost" + # Expose the port the app runs on EXPOSE 3000 diff --git a/docs/sdk-upgrade.md b/docs/sdk-upgrade.md index cf03ed0e37d..b6f79973aaa 100644 --- a/docs/sdk-upgrade.md +++ b/docs/sdk-upgrade.md @@ -41,7 +41,7 @@ Steps: 6. Inspect the `Sources` of the page. Expand the local IP address from which you are serving the page. You should see a folder with a version number in the name, like `acuant/11.N.N`. Check that the version here is the new one — the version you noted in step 1. This screenshot shows where the version number appears in Chrome: - ![acuant-vesion-location](https://user-images.githubusercontent.com/546123/232644328-35922329-ad30-489e-943f-4125c009f74d.png) + ![acuant-version-location](https://user-images.githubusercontent.com/546123/232644328-35922329-ad30-489e-943f-4125c009f74d.png) 7. Assuming the version is correct, you are ready to test it. On your phone, tap to photograph your state ID card. Point the camera at the card. Ensure the SDK finds the edges of the card and captures an image. Normally the SDK will put a yellowish box over the card to show where it believes the edges are. @@ -87,6 +87,14 @@ Steps: Set the default to the new SDK version and the alternate to the old version. (That way, the new version is in place if the A/B testing goes well.) + **Note**: For testing in `staging`, `idv_acuant_sdk_upgrade_a_b_testing_enabled` can be set to `false` like following to test the new SDK version: + ```yaml + idv_acuant_sdk_upgrade_a_b_testing_enabled: false + idv_acuant_sdk_upgrade_a_b_testing_percent: 50 # ignored + idv_acuant_sdk_version_alternate: 11.M.M # previous + idv_acuant_sdk_version_default: 11.N.N # newest + ``` + The testing phase should continue until we have accumulated sufficient traffic. 4. Save the file. If the file opened in the vi editor, use `:wq` to save. A diff of your changes will appear. Copy the diff and paste it into the Slack thread. Type `y` to accept the changes. 5. Recycle the servers [with these Handbook instructions](https://handbook.login.gov/articles/appdev-deploy.html#production). This will involve: @@ -98,6 +106,26 @@ Steps: 6. While you monitor the recycle, manually check the document capture page in the environment you are deploying to. Ensure the SDK loads and can capture images. Monitoring the A/B test begins now. Proceed to the next section. +## Testing Considerations +Manual testing should be performed to cover the following with verification *Success* or *Failure*: +* SDK UI + * Camera permission prompt is shown + * Instruction text for taking ID and selfie + * Countdown while capturing + * Auto-capture mode +* Camera permissions + * Prompt is shown upon the first time opening the SDK + * Tapping 'Decline' shows error message on the 'Add photos' page + * Opening the SDK again shows the same prompt + +Operating systems: + * iOS + * Android + +Browser: + * Chrome + * Firefox + * Safari ## Monitor A/B testing diff --git a/package.json b/package.json index 16b3ef2886d..c1c8fb04671 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,7 @@ "source-map-loader": "^4.0.0", "webpack": "^5.76.1", "webpack-assets-manifest": "^5.1.0", - "webpack-cli": "^4.10.0", - "zxcvbn": "4.4.2" + "webpack-cli": "^4.10.0" }, "devDependencies": { "@babel/cli": "^7.22.15", @@ -61,6 +60,7 @@ "@types/react-dom": "^17.0.11", "@types/sinon": "^10.0.13", "@types/sinon-chai": "^3.2.8", + "@types/zxcvbn": "^4.4.4", "@typescript-eslint/eslint-plugin": "^6.7.5", "@typescript-eslint/parser": "^6.7.5", "chai": "^4.3.10", diff --git a/spec/components/manageable_authenticator_component_spec.rb b/spec/components/manageable_authenticator_component_spec.rb index 20d438abf24..56eed6d93ce 100644 --- a/spec/components/manageable_authenticator_component_spec.rb +++ b/spec/components/manageable_authenticator_component_spec.rb @@ -56,11 +56,10 @@ expect(edit_element.attr('tabindex')).to be_present expect(edit_element).to have_name( - format( - '%s: %s', + [ t('components.manageable_authenticator.manage_accessible_label'), configuration.name, - ), + ].join(': '), ) end @@ -80,11 +79,10 @@ it 'renders with buttons that have accessibly distinct manage label' do expect(rendered).to have_button( - format( - '%s: %s', + [ t('components.manageable_authenticator.manage_accessible_label'), configuration.name, - ), + ].join(': '), ) end @@ -147,7 +145,7 @@ let(:custom_strings) { { manage_accessible_label: custom_manage_accessible_label } } it 'overrides button label and affected linked content' do - manage_label = format('%s: %s', custom_manage_accessible_label, configuration.name) + manage_label = [custom_manage_accessible_label, configuration.name].join(': ') expect(rendered).to have_button(manage_label) edit_element = page.find_css('.manageable-authenticator__edit').first expect(edit_element).to have_name(manage_label) diff --git a/spec/components/password_confirmation_component_spec.rb b/spec/components/password_confirmation_component_spec.rb index 0116d517731..f88d773359a 100644 --- a/spec/components/password_confirmation_component_spec.rb +++ b/spec/components/password_confirmation_component_spec.rb @@ -3,9 +3,10 @@ RSpec.describe PasswordConfirmationComponent, type: :component do let(:view_context) { vc_test_controller.view_context } let(:form) { SimpleForm::FormBuilder.new('', {}, view_context, {}) } + let(:options) { { form: } } subject(:rendered) do - render_inline PasswordConfirmationComponent.new(form:) + render_inline PasswordConfirmationComponent.new(**options) end it 'renders password fields with expected attributes' do @@ -22,15 +23,9 @@ end context 'with labels passed in' do - subject(:rendered) do - render_inline PasswordConfirmationComponent.new( - form:, - password_label: password_label, - confirmation_label: confirmation_label, - ) - end let(:password_label) { 'edited password label' } let(:confirmation_label) { 'edited password confirmation label' } + let(:options) { super().merge(password_label:, confirmation_label:) } it 'renders custom password label' do expect(rendered).to have_content(password_label) @@ -40,4 +35,17 @@ expect(rendered).to have_content(confirmation_label) end end + + context 'with forbidden passwords' do + let(:forbidden_passwords) { ['password'] } + let(:options) { super().merge(forbidden_passwords:) } + + it 'forwards forbidden passwords to rendered password strength component' do + expect(PasswordStrengthComponent).to receive(:new). + with(hash_including(forbidden_passwords:)). + and_call_original + + rendered + end + end end diff --git a/spec/components/password_strength_component_spec.rb b/spec/components/password_strength_component_spec.rb new file mode 100644 index 00000000000..1f2d7d6ba87 --- /dev/null +++ b/spec/components/password_strength_component_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +RSpec.describe PasswordStrengthComponent, type: :component do + let(:input_id) { 'input' } + let(:options) { { input_id: } } + + subject(:rendered) { render_inline PasswordStrengthComponent.new(**options) } + + it 'renders with default attributes' do + element = rendered.at_css('lg-password-strength') + + expect(element.attr('input-id')).to eq('input') + expect(element.attr('minimum-length')).to eq('12') + expect(element.attr('forbidden-passwords')).to eq('[]') + expect(element.attr('class')).to eq('display-none') + end + + context 'with customized options' do + let(:minimum_length) { 10 } + let(:forbidden_passwords) { ['password'] } + let(:tag_options) { { class: 'example-class', data: { foo: 'bar' } } } + let(:options) { super().merge(minimum_length:, forbidden_passwords:, **tag_options) } + + it 'renders with customized option attributes' do + element = rendered.at_css('lg-password-strength') + + expect(element.attr('minimum-length')).to eq('10') + expect(element.attr('forbidden-passwords')).to eq('["password"]') + expect(element.attr('class').split(' ')).to match_array(['example-class', 'display-none']) + expect(element.attr('data-foo')).to eq('bar') + end + end +end diff --git a/spec/components/password_toggle_component_spec.rb b/spec/components/password_toggle_component_spec.rb index 726a9f8a27f..efdd3c434a5 100644 --- a/spec/components/password_toggle_component_spec.rb +++ b/spec/components/password_toggle_component_spec.rb @@ -64,6 +64,15 @@ expect(toggle_two.input_id).to be_present expect(toggle_one.input_id).not_to eq(toggle_two.input_id) end + + context 'with field_options customizing id' do + let(:options) { { field_options: { input_html: { id: 'custom' } } } } + + it 'respects customized id' do + expect(rendered).to have_css('#custom[type=password]') + expect(rendered).to have_css('[aria-controls=custom]') + end + end end context 'with tag options' do diff --git a/spec/components/previews/password_strength_component_preview.rb b/spec/components/previews/password_strength_component_preview.rb new file mode 100644 index 00000000000..aa1b954072c --- /dev/null +++ b/spec/components/previews/password_strength_component_preview.rb @@ -0,0 +1,31 @@ +class PasswordStrengthComponentPreview < BaseComponentPreview + # @after_render :inject_input_html + # @!group Preview + def default + render(PasswordStrengthComponent.new(input_id: 'preview-input')) + end + # @!endgroup + + # @after_render :inject_input_html + # @param minimum_length text + # @param forbidden_passwords text + def workbench(minimum_length: '12', forbidden_passwords: 'password') + render( + PasswordStrengthComponent.new( + input_id: 'preview-input', + minimum_length:, + forbidden_passwords: forbidden_passwords.split(','), + ), + ) + end + + private + + def inject_input_html(html, _context) + <<~HTML + + + #{html} + HTML + end +end diff --git a/spec/controllers/api/internal/two_factor_authentication/webauthn_controller_spec.rb b/spec/controllers/api/internal/two_factor_authentication/webauthn_controller_spec.rb index 38067823cb0..513a0e4c424 100644 --- a/spec/controllers/api/internal/two_factor_authentication/webauthn_controller_spec.rb +++ b/spec/controllers/api/internal/two_factor_authentication/webauthn_controller_spec.rb @@ -27,6 +27,7 @@ :webauthn_update_name_submitted, success: true, configuration_id: configuration.id.to_s, + platform_authenticator: false, error_details: nil, ) end @@ -60,6 +61,7 @@ :webauthn_update_name_submitted, success: false, configuration_id: configuration.id.to_s, + platform_authenticator: false, error_details: { name: { blank: true } }, ) end @@ -118,6 +120,7 @@ :webauthn_delete_submitted, success: true, configuration_id: configuration.id.to_s, + platform_authenticator: false, error_details: nil, ) end @@ -174,6 +177,7 @@ :webauthn_delete_submitted, success: false, configuration_id: configuration.id.to_s, + platform_authenticator: false, error_details: { configuration_id: { only_method: true } }, ) end diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index 9a5c3a8595e..73cdc8f8cf1 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -125,6 +125,61 @@ expect(response).to be_bad_request end + + context 'cert element in SAML request is blank' do + let(:user) { create(:user, :fully_registered) } + let(:service_provider) { build(:service_provider, issuer: 'http://localhost:3000') } + + # the RubySAML library won't let us pass an empty string in as the certificate + # element, so this test substitutes a SAMLRequest that has that element blank + let(:blank_cert_element_req) do + <<-XML.gsub(/^[\s\t]*|[\s\t]*\n/, '') + + + http://localhost:3000 + + + + + + + + + + + + + 2Nb3RLbiFHn0cyn+7JA7hWbbK1NvFMVGa4MYTb3Q91I= + + + UmsRcaWkHXrUnBMfOQBC2DIQk1rkQqMc5oucz6FAjulq0ZX7qT+zUbSZ7K/us+lzcL1hrgHXi2wxjKSRiisWrJNSmbIGGZIa4+U8wIMhkuY5vZVKgxRc2aP88i/lWwURMI183ifAzCwpq5Y4yaJ6pH+jbgYOtmOhcXh1OwrI+QqR7QSglyUJ55WO+BCR07Hf8A7DSA/Wgp9xH+DUw1EnwbDdzoi7TFqaHY8S4SWIcc26DHsq88mjsmsxAFRQ+4t6nadOnrrFnJWKJeiFlD8MxcQuBiuYBetKRLIPxyXKFxjEn7EkJ5zDkkrBWyUT4VT/JnthUlD825D+v81ZXIX3Tg== + + + + + + + + _13ae90d1-2f9b-4ed5-b84d-3722ea42e386 + + XML + end + let(:deflated_encoded_req) do + Base64.encode64(Zlib::Deflate.deflate(blank_cert_element_req, 9)[2..-5]) + end + + it 'a ValidationError is raised' do + expect do + delete :logout, params: { + 'SAMLRequest' => deflated_encoded_req, + path_year:, + } + end.to raise_error( + SamlIdp::XMLSecurity::SignedDocument::ValidationError, + 'Certificate element present in response (ds:X509Certificate) but evaluating to nil', + ) + end + end end describe '/api/saml/remotelogout' do @@ -1103,7 +1158,7 @@ def name_id_version(format_urn) ) end - it 'deoes not blow up' do + it 'does not blow up' do user = create(:user, :fully_registered) expect { generate_saml_response(user, second_cert_settings) }.to_not raise_error @@ -1304,6 +1359,87 @@ def name_id_version(format_urn) end end + context 'cert element in SAML request is blank' do + let(:user) { create(:user, :fully_registered) } + let(:service_provider) { build(:service_provider, issuer: 'http://localhost:3000') } + let(:analytics_hash) do + { + success: false, + errors: { service_provider: ['We cannot detect a certificate in your request.'] }, + error_details: { service_provider: { blank_cert_element_req: true } }, + nameid_format: Saml::Idp::Constants::NAME_ID_FORMAT_PERSISTENT, + authn_context: [Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF], + authn_context_comparison: 'exact', + service_provider: 'http://localhost:3000', + request_signed: true, + } + end + + before do + stub_analytics + allow(@analytics).to receive(:track_event) + end + + # the RubySAML library won't let us pass an empty string in as the certificate + # element, so this test substitutes a SAMLRequest that has that element blank + let(:blank_cert_element_req) do + <<-XML.gsub(/^[\s\t]*|[\s\t]*\n/, '') + + + http://localhost:3000 + + + + + + + + + + + + + aoHPDDUZTRSIVsbuE954QKbo6StafYvbVUPU+p33m8E= + + + JH0VD0SLKawSS9tnlUxUL2fYVCza4MT6L79aRiKQi56+arGfnPHZ21cIYOEHxDn2xIg6EV6tda+WwOP9WTrsuqJLAfTWLz9Ah2A8ukITIOYED5WboiodLr5sjkr4HFKwRjERtLycLaxDt8Ya9tHQa5mOjln8yIWFDLdf89jnXaTM9gReq2k1MpI3YlhIYHJMALY5NxbOPTTmWeXdiUUYH/Irq2jzXrI+2ruyCZt8Xpo9tfosFGnoTGFkeK7sWOmndle2WqRE29k4S582JJtXgi4A8JDGw0KK8zM4JttxpK+DbowN8wJ4gWpgRppkBi5e6JiV4W0DNgZC72WHjXULQg== + + + + + + + + + urn:gov:gsa:ac:classes:sp:PasswordProtectedTransport:duo + + + XML + end + let(:deflated_encoded_req) do + Base64.encode64(Zlib::Deflate.deflate(blank_cert_element_req, 9)[2..-5]) + end + + before do + IdentityLinker.new(user, service_provider).link_identity + user.identities.last.update!(verified_attributes: ['email']) + expect(CGI).to receive(:unescape).and_return deflated_encoded_req + end + + it 'notes it in the analytics event' do + generate_saml_response(user, saml_settings) + expect(@analytics).to have_received(:track_event). + with('SAML Auth', analytics_hash) + end + + it 'returns a 400' do + generate_saml_response(user, saml_settings) + expect(controller).to render_template('saml_idp/auth/error') + expect(response.status).to eq(400) + expect(response.body).to include(t('errors.messages.blank_cert_element_req')) + end + end + context 'no IAL explicitly requested' do let(:user) { create(:user, :fully_registered) } @@ -2146,9 +2282,9 @@ def stub_requested_attributes expect(subject).to have_actions( :before, :disable_caching, - :validate_saml_request, - :validate_service_provider_and_authn_context, :store_saml_request, + :validate_and_create_saml_request_object, + :validate_service_provider_and_authn_context, ) end end diff --git a/spec/controllers/users/webauthn_controller_spec.rb b/spec/controllers/users/webauthn_controller_spec.rb index 4a9d5792ea7..836ebf525b7 100644 --- a/spec/controllers/users/webauthn_controller_spec.rb +++ b/spec/controllers/users/webauthn_controller_spec.rb @@ -13,11 +13,12 @@ let(:params) { { id: configuration.id } } let(:response) { get :edit, params: params } - it 'assigns the form instance' do + it 'assigns the form and presenter instances' do response expect(assigns(:form)).to be_kind_of(TwoFactorAuthentication::WebauthnUpdateForm) expect(assigns(:form).configuration).to eq(configuration) + expect(assigns(:presenter)).to be_kind_of(TwoFactorAuthentication::WebauthnEditPresenter) end context 'signed out' do @@ -63,7 +64,7 @@ it 'redirects to account page with success message' do expect(response).to redirect_to(account_path) - expect(flash[:success]).to eq(t('two_factor_authentication.webauthn_platform.renamed')) + expect(flash[:success]).to eq(t('two_factor_authentication.webauthn_roaming.renamed')) end it 'assigns the form instance' do @@ -80,6 +81,7 @@ :webauthn_update_name_submitted, success: true, configuration_id: configuration.id.to_s, + platform_authenticator: false, error_details: nil, ) end @@ -96,6 +98,14 @@ context 'with invalid submission' do let(:name) { '' } + it 'assigns form and presenter instances' do + response + + expect(assigns(:form)).to be_kind_of(TwoFactorAuthentication::WebauthnUpdateForm) + expect(assigns(:form).configuration).to eq(configuration) + expect(assigns(:presenter)).to be_kind_of(TwoFactorAuthentication::WebauthnEditPresenter) + end + it 'renders edit template with error' do expect(response).to render_template(:edit) expect(flash.now[:error]).to eq(t('errors.messages.blank')) @@ -108,6 +118,7 @@ :webauthn_update_name_submitted, success: false, configuration_id: configuration.id.to_s, + platform_authenticator: false, error_details: { name: { blank: true } }, ) end @@ -146,7 +157,7 @@ it 'responds with successful result' do expect(response).to redirect_to(account_path) - expect(flash[:success]).to eq(t('two_factor_authentication.webauthn_platform.deleted')) + expect(flash[:success]).to eq(t('two_factor_authentication.webauthn_roaming.deleted')) end it 'logs the submission attempt' do @@ -156,6 +167,7 @@ :webauthn_delete_submitted, success: true, configuration_id: configuration.id.to_s, + platform_authenticator: false, error_details: nil, ) end @@ -211,6 +223,7 @@ :webauthn_delete_submitted, success: false, configuration_id: configuration.id.to_s, + platform_authenticator: false, error_details: { configuration_id: { only_method: true } }, ) end diff --git a/spec/controllers/users/webauthn_setup_controller_spec.rb b/spec/controllers/users/webauthn_setup_controller_spec.rb index efa3a03fbe9..483c02b6cdc 100644 --- a/spec/controllers/users/webauthn_setup_controller_spec.rb +++ b/spec/controllers/users/webauthn_setup_controller_spec.rb @@ -156,6 +156,7 @@ success: true, error_details: nil, configuration_id: webauthn_configuration.id.to_s, + platform_authenticator: false, ) end @@ -165,6 +166,22 @@ delete :delete, params: { id: webauthn_configuration.id } end + + context 'when authenticator is the sole authentication method' do + let(:user) { create(:user) } + + it 'tracks the delete in analytics' do + delete :delete, params: { id: webauthn_configuration.id } + + expect(@analytics).to have_logged_event( + :webauthn_delete_submitted, + success: false, + error_details: nil, + configuration_id: webauthn_configuration.id.to_s, + platform_authenticator: nil, + ) + end + end end describe 'show_delete' do diff --git a/spec/features/event_disavowal_spec.rb b/spec/features/event_disavowal_spec.rb index 661bedc7130..41fc36ea09c 100644 --- a/spec/features/event_disavowal_spec.rb +++ b/spec/features/event_disavowal_spec.rb @@ -131,6 +131,24 @@ expect(page.current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) end + scenario 'disavowing an event with javascript enabled', :js do + perform_disavowable_password_reset + + open_last_email + click_email_link_matching(%r{events/disavow}) + + expect(page).to have_content(t('headings.passwords.change')) + + fill_in t('forms.passwords.edit.labels.password'), with: 'abc' + + expect(page).to have_content t('zxcvbn.feedback.sequences_like_abc_or_6543_are_easy_to_guess') + + fill_in t('forms.passwords.edit.labels.password'), with: 'NewVal!dPassw0rd' + click_button t('forms.passwords.edit.buttons.submit') + + expect(page).to have_content(t('devise.passwords.updated_not_active')) + end + def submit_prefilled_otp_code(user, delivery_preference) expect(current_path). to eq login_two_factor_path(otp_delivery_preference: delivery_preference) diff --git a/spec/features/saml/saml_spec.rb b/spec/features/saml/saml_spec.rb index 5cb5a3c1a84..a8870235201 100644 --- a/spec/features/saml/saml_spec.rb +++ b/spec/features/saml/saml_spec.rb @@ -34,17 +34,11 @@ sp.save end - it 'returns the user to the account page after authentication' do - expect(page). - to have_link t('links.back_to_sp', sp: sp.friendly_name), - href: return_to_sp_cancel_path(step: :authentication) - + it 'returns a 403' do sign_in_via_branded_page(user) click_submit_default - click_agree_and_continue - click_submit_default - expect(current_url).to eq account_url + expect(page.status_code).to eq 403 end end diff --git a/spec/features/users/user_profile_spec.rb b/spec/features/users/user_profile_spec.rb index 3c134230883..edc060d8b99 100644 --- a/spec/features/users/user_profile_spec.rb +++ b/spec/features/users/user_profile_spec.rb @@ -123,7 +123,7 @@ with: 'this is a great sentence' expect(page).to have_content(t('instructions.password.strength.intro')) - expect(page).to have_content t('instructions.password.strength.v') + expect(page).to have_content t('instructions.password.strength.4') check t('components.password_toggle.toggle_label') diff --git a/spec/features/visitors/set_password_spec.rb b/spec/features/visitors/set_password_spec.rb index 13556b8c812..6c7edfe9d99 100644 --- a/spec/features/visitors/set_password_spec.rb +++ b/spec/features/visitors/set_password_spec.rb @@ -28,7 +28,7 @@ create(:user, :unconfirmed) confirm_last_user - expect(page).to have_css('#pw-strength-cntnr.display-none') + expect(page).to have_css('lg-password-strength.display-none') end context 'password strength indicator when JS is on', js: true do @@ -42,13 +42,13 @@ fill_in t('forms.password'), with: 'password' expect(page).to have_content(t('instructions.password.strength.intro')) - expect(page).to have_content t('instructions.password.strength.i') + expect(page).to have_content t('instructions.password.strength.0') fill_in t('forms.password'), with: '123456789' expect(page).to have_content t('zxcvbn.feedback.this_is_a_top_10_common_password') fill_in t('forms.password'), with: 'this is a great sentence' - expect(page).to have_content t('instructions.password.strength.v') + expect(page).to have_content t('instructions.password.strength.4') fill_in t('forms.password'), with: ':b/}6tT#,' expect(page).to have_content t('errors.attributes.password.too_short.other', count: 12) diff --git a/spec/features/webauthn/management_spec.rb b/spec/features/webauthn/management_spec.rb index f7ea5e8bbf0..20e91e42226 100644 --- a/spec/features/webauthn/management_spec.rb +++ b/spec/features/webauthn/management_spec.rb @@ -48,9 +48,9 @@ def expect_webauthn_platform_setup_error end context 'with webauthn roaming associations' do - it 'displays the user supplied names of the security keys' do - webauthn_config1 = create(:webauthn_configuration, user: user) - webauthn_config2 = create(:webauthn_configuration, user: user) + it 'displays the user supplied names of the platform authenticators' do + webauthn_config1 = create(:webauthn_configuration, user:) + webauthn_config2 = create(:webauthn_configuration, user:) sign_in_and_2fa_user(user) visit account_two_factor_authentication_path @@ -59,15 +59,15 @@ def expect_webauthn_platform_setup_error expect(page).to have_content webauthn_config2.name end - it 'allows the user to setup another key' do + it 'allows the user to setup another roaming authenticator' do mock_webauthn_setup_challenge - create(:webauthn_configuration, user: user) + create(:webauthn_configuration, user:) sign_in_and_2fa_user(user) visit_webauthn_setup - expect(current_path).to eq webauthn_setup_path + expect(page).to have_current_path webauthn_setup_path fill_in_nickname_and_click_continue mock_press_button_on_hardware_key_on_setup @@ -75,41 +75,147 @@ def expect_webauthn_platform_setup_error expect_webauthn_setup_success end - it 'allows user to delete security key when another 2FA option is set up' do - webauthn_config = create(:webauthn_configuration, user: user) + it 'allows user to delete a roaming authenticator when another 2FA option is set up' do + webauthn_config = create(:webauthn_configuration, user:) + name = webauthn_config.name sign_in_and_2fa_user(user) visit account_two_factor_authentication_path - expect(page).to have_content webauthn_config.name + expect(page).to have_content(name) - click_link t('account.index.webauthn_delete') + click_link( + [ + t('two_factor_authentication.webauthn_roaming.manage_accessible_label'), + name, + ].join(': '), + ) - expect(current_path).to eq webauthn_setup_delete_path + expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id)) - click_button t('account.index.webauthn_confirm_delete') + click_button t('two_factor_authentication.webauthn_roaming.delete') - expect(page).to_not have_content webauthn_config.name - expect(page).to have_content t('notices.webauthn_deleted') + expect(page).to_not have_content(name) + expect(page).to have_content(t('two_factor_authentication.webauthn_roaming.deleted')) expect(user.reload.webauthn_configurations.empty?).to eq(true) end - it 'prevents a user from deleting the last key' do - webauthn_config = create(:webauthn_configuration, user: user) + it 'allows user to rename a roaming authenticator' do + webauthn_config = create(:webauthn_configuration, user:) + name = webauthn_config.name + + sign_in_and_2fa_user(user) + visit account_two_factor_authentication_path + + expect(page).to have_content(name) + + click_link( + [ + t('two_factor_authentication.webauthn_roaming.manage_accessible_label'), + name, + ].join(': '), + ) + + expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id)) + expect(page).to have_field( + t('two_factor_authentication.webauthn_roaming.nickname'), + with: name, + ) + + fill_in t('two_factor_authentication.webauthn_roaming.nickname'), with: 'new name' + + click_button t('two_factor_authentication.webauthn_roaming.change_nickname') + + expect(page).to have_content('new name') + expect(page).to have_content(t('two_factor_authentication.webauthn_roaming.renamed')) + end + + it 'allows the user to cancel deletion of the roaming authenticator' do + webauthn_config = create(:webauthn_configuration, user:) + name = webauthn_config.name + + sign_in_and_2fa_user(user) + visit account_two_factor_authentication_path + + expect(page).to have_content(name) + + click_link( + [ + t('two_factor_authentication.webauthn_roaming.manage_accessible_label'), + name, + ].join(': '), + ) + + expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id)) + + click_link t('links.cancel') + + expect(page).to have_content(name) + end + + it 'prevents a user from deleting the last roaming authenticator' do + webauthn_config = create(:webauthn_configuration, user:) + name = webauthn_config.name sign_in_and_2fa_user(user) PhoneConfiguration.first.update(mfa_enabled: false) user.backup_code_configurations.destroy_all - visit account_two_factor_authentication_path - expect(current_path).to eq account_two_factor_authentication_path + expect(page).to have_content(name) + + click_link( + [ + t('two_factor_authentication.webauthn_roaming.manage_accessible_label'), + name, + ].join(': '), + ) - expect(page).to have_content webauthn_config.name - expect(page).to_not have_link t('account.index.webauthn_delete') + expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id)) + + click_button t('two_factor_authentication.webauthn_roaming.delete') + + expect(page).to have_current_path(edit_webauthn_path(id: webauthn_config.id)) + expect(page).to have_content(t('errors.manage_authenticator.remove_only_method_error')) + expect(user.reload.webauthn_configurations.empty?).to eq(false) + end + + it 'requires a user to use a unique name when renaming' do + webauthn_config = create(:webauthn_configuration, user:) + create(:webauthn_configuration, user:, name: 'existing') + name = webauthn_config.name + + sign_in_and_2fa_user(user) + + expect(page).to have_content(name) + + click_link( + [ + t('two_factor_authentication.webauthn_roaming.manage_accessible_label'), + name, + ].join(': '), + ) + + expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id)) + expect(page).to have_field( + t('two_factor_authentication.webauthn_roaming.nickname'), + with: name, + ) + + fill_in t('two_factor_authentication.webauthn_roaming.nickname'), with: 'existing' + + click_button t('two_factor_authentication.webauthn_roaming.change_nickname') + + expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id)) + expect(page).to have_field( + t('two_factor_authentication.webauthn_roaming.nickname'), + with: 'existing', + ) + expect(page).to have_content(t('errors.manage_authenticator.unique_name_error')) + expect(page).to have_css('.usa-input--error') end it 'gives an error if name is taken and stays on the configuration screen' do - webauthn_config = create(:webauthn_configuration, user: user) + webauthn_config = create(:webauthn_configuration, user:) mock_webauthn_setup_challenge sign_in_and_2fa_user(user) @@ -126,6 +232,120 @@ def expect_webauthn_platform_setup_error expect(current_path).to eq webauthn_setup_path expect(page).to have_content t('errors.webauthn_setup.unique_name') end + + context 'with javascript enabled', :js do + it 'allows user to delete a roaming authenticator when another 2FA option is set up' do + webauthn_config = create(:webauthn_configuration, user:) + name = webauthn_config.name + + sign_in_and_2fa_user(user) + visit account_two_factor_authentication_path + + expect(page).to have_content(name) + + click_button( + [ + t('two_factor_authentication.webauthn_roaming.manage_accessible_label'), + name, + ].join(': '), + ) + + # Verify user can cancel deletion. There's an implied assertion here that the button becomes + # clickable again, since the following confirmation occurs upon successive button click. + dismiss_confirm(wait: 5) { click_button t('components.manageable_authenticator.delete') } + + # Verify user confirms deletion + accept_confirm(wait: 5) { click_button t('components.manageable_authenticator.delete') } + + expect(page).to have_content( + t('two_factor_authentication.webauthn_roaming.deleted'), + wait: 5, + ) + expect(page).to_not have_content(name) + expect(user.reload.webauthn_configurations.empty?).to eq(true) + end + + it 'allows user to rename a roaming authenticator' do + webauthn_config = create(:webauthn_configuration, user:) + name = webauthn_config.name + + sign_in_and_2fa_user(user) + visit account_two_factor_authentication_path + + expect(page).to have_content(name) + + click_button( + [ + t('two_factor_authentication.webauthn_roaming.manage_accessible_label'), + name, + ].join(': '), + ) + click_button t('components.manageable_authenticator.rename') + + expect(page).to have_field(t('components.manageable_authenticator.nickname'), with: name) + + fill_in t('components.manageable_authenticator.nickname'), with: 'new name' + + click_button t('components.manageable_authenticator.save') + + expect(page).to have_content( + t('two_factor_authentication.webauthn_roaming.renamed'), + wait: 5, + ) + expect(page).to have_content('new name') + end + + it 'prevents a user from deleting the last roaming authenticator', allow_browser_log: true do + webauthn_config = create(:webauthn_configuration, user:) + name = webauthn_config.name + + sign_in_and_2fa_user(user) + PhoneConfiguration.first.update(mfa_enabled: false) + user.backup_code_configurations.destroy_all + + expect(page).to have_content(name) + + click_button( + [ + t('two_factor_authentication.webauthn_roaming.manage_accessible_label'), + name, + ].join(': '), + ) + accept_confirm(wait: 5) { click_button t('components.manageable_authenticator.delete') } + + expect(page).to have_content( + t('errors.manage_authenticator.remove_only_method_error'), + wait: 5, + ) + expect(user.reload.webauthn_configurations.empty?).to eq(false) + end + + it 'requires a user to use a unique name when renaming', allow_browser_log: true do + webauthn_config = create(:webauthn_configuration, user:) + create(:webauthn_configuration, user:, name: 'existing') + name = webauthn_config.name + + sign_in_and_2fa_user(user) + + expect(page).to have_content(name) + + click_button( + [ + t('two_factor_authentication.webauthn_roaming.manage_accessible_label'), + name, + ].join(': '), + ) + click_button t('components.manageable_authenticator.rename') + + expect(page).to have_field(t('components.manageable_authenticator.nickname'), with: name) + + fill_in t('components.manageable_authenticator.nickname'), with: 'existing' + + click_button t('components.manageable_authenticator.save') + + expect(page).to have_content(t('errors.manage_authenticator.unique_name_error'), wait: 5) + end + end end context 'with webauthn platform associations' do @@ -140,7 +360,7 @@ def expect_webauthn_platform_setup_error expect(page).to have_content webauthn_config2.name end - it 'allows the user to setup another key' do + it 'allows the user to setup another platform authenticator' do mock_webauthn_setup_challenge create(:webauthn_configuration, :platform_authenticator, user:) @@ -175,11 +395,10 @@ def expect_webauthn_platform_setup_error expect(page).to have_content(name) click_link( - format( - '%s: %s', + [ t('two_factor_authentication.webauthn_platform.manage_accessible_label'), name, - ), + ].join(': '), ) expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id)) @@ -201,11 +420,10 @@ def expect_webauthn_platform_setup_error expect(page).to have_content(name) click_link( - format( - '%s: %s', + [ t('two_factor_authentication.webauthn_platform.manage_accessible_label'), name, - ), + ].join(': '), ) expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id)) @@ -232,11 +450,10 @@ def expect_webauthn_platform_setup_error expect(page).to have_content(name) click_link( - format( - '%s: %s', + [ t('two_factor_authentication.webauthn_platform.manage_accessible_label'), name, - ), + ].join(': '), ) expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id)) @@ -246,7 +463,7 @@ def expect_webauthn_platform_setup_error expect(page).to have_content(name) end - it 'prevents a user from deleting the last key' do + it 'prevents a user from deleting the last platform authenticator' do webauthn_config = create(:webauthn_configuration, :platform_authenticator, user:) name = webauthn_config.name @@ -257,11 +474,10 @@ def expect_webauthn_platform_setup_error expect(page).to have_content(name) click_link( - format( - '%s: %s', + [ t('two_factor_authentication.webauthn_platform.manage_accessible_label'), name, - ), + ].join(': '), ) expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id)) @@ -283,11 +499,10 @@ def expect_webauthn_platform_setup_error expect(page).to have_content(name) click_link( - format( - '%s: %s', + [ t('two_factor_authentication.webauthn_platform.manage_accessible_label'), name, - ), + ].join(': '), ) expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id)) @@ -306,6 +521,7 @@ def expect_webauthn_platform_setup_error with: 'existing', ) expect(page).to have_content(t('errors.manage_authenticator.unique_name_error')) + expect(page).to have_css('.usa-input--error') end it 'gives an error if name is taken and stays on the configuration screen' do @@ -338,11 +554,10 @@ def expect_webauthn_platform_setup_error expect(page).to have_content(name) click_button( - format( - '%s: %s', + [ t('two_factor_authentication.webauthn_platform.manage_accessible_label'), name, - ), + ].join(': '), ) # Verify user can cancel deletion. There's an implied assertion here that the button becomes @@ -370,11 +585,10 @@ def expect_webauthn_platform_setup_error expect(page).to have_content(name) click_button( - format( - '%s: %s', + [ t('two_factor_authentication.webauthn_platform.manage_accessible_label'), name, - ), + ].join(': '), ) click_button t('components.manageable_authenticator.rename') @@ -391,7 +605,7 @@ def expect_webauthn_platform_setup_error expect(page).to have_content('new name') end - it 'prevents a user from deleting the last key', allow_browser_log: true do + it 'prevents a user from deleting the last platform authenticator', allow_browser_log: true do webauthn_config = create(:webauthn_configuration, :platform_authenticator, user:) name = webauthn_config.name @@ -402,11 +616,10 @@ def expect_webauthn_platform_setup_error expect(page).to have_content(name) click_button( - format( - '%s: %s', + [ t('two_factor_authentication.webauthn_platform.manage_accessible_label'), name, - ), + ].join(': '), ) accept_confirm(wait: 5) { click_button t('components.manageable_authenticator.delete') } @@ -427,11 +640,10 @@ def expect_webauthn_platform_setup_error expect(page).to have_content(name) click_button( - format( - '%s: %s', + [ t('two_factor_authentication.webauthn_platform.manage_accessible_label'), name, - ), + ].join(': '), ) click_button t('components.manageable_authenticator.rename') diff --git a/spec/forms/two_factor_authentication/webauthn_delete_form_spec.rb b/spec/forms/two_factor_authentication/webauthn_delete_form_spec.rb index eb28c95004a..f0c210aceb9 100644 --- a/spec/forms/two_factor_authentication/webauthn_delete_form_spec.rb +++ b/spec/forms/two_factor_authentication/webauthn_delete_form_spec.rb @@ -14,7 +14,21 @@ it 'returns a successful result' do expect(result.success?).to eq(true) - expect(result.to_h).to eq(success: true, configuration_id:) + expect(result.to_h).to eq( + success: true, + configuration_id:, + platform_authenticator: false, + ) + end + + context 'with platform authenticator' do + let(:configuration) do + create(:webauthn_configuration, :platform_authenticator, user:) + + it 'includes platform authenticator detail in result' do + expect(result.to_h[:platform_authenticator]).to eq(true) + end + end end context 'with blank configuration' do @@ -28,6 +42,7 @@ configuration_id: { configuration_not_found: true }, }, configuration_id:, + platform_authenticator: nil, ) end end @@ -43,6 +58,7 @@ configuration_id: { configuration_not_found: true }, }, configuration_id:, + platform_authenticator: nil, ) end end @@ -58,6 +74,7 @@ configuration_id: { configuration_not_found: true }, }, configuration_id:, + platform_authenticator: nil, ) end end @@ -74,8 +91,19 @@ configuration_id: { only_method: true }, }, configuration_id:, + platform_authenticator: false, ) end + + context 'with platform authenticator' do + let(:configuration) do + create(:webauthn_configuration, :platform_authenticator, user:) + + it 'includes platform authenticator detail in result' do + expect(result.to_h[:platform_authenticator]).to eq(true) + end + end + end end end diff --git a/spec/forms/two_factor_authentication/webauthn_update_form_spec.rb b/spec/forms/two_factor_authentication/webauthn_update_form_spec.rb index 8f85687ecbd..4d371bd7822 100644 --- a/spec/forms/two_factor_authentication/webauthn_update_form_spec.rb +++ b/spec/forms/two_factor_authentication/webauthn_update_form_spec.rb @@ -13,7 +13,21 @@ it 'returns a successful result' do expect(result.success?).to eq(true) - expect(result.to_h).to eq(success: true, configuration_id:) + expect(result.to_h).to eq( + success: true, + configuration_id:, + platform_authenticator: false, + ) + end + + context 'with platform authenticator' do + let(:configuration) do + create(:webauthn_configuration, :platform_authenticator, user:, name: original_name) + + it 'includes platform authenticator detail in result' do + expect(result.to_h[:platform_authenticator]).to eq(true) + end + end end it 'saves the new name' do @@ -33,6 +47,7 @@ configuration_id: { configuration_not_found: true }, }, configuration_id:, + platform_authenticator: nil, ) end end @@ -48,6 +63,7 @@ configuration_id: { configuration_not_found: true }, }, configuration_id:, + platform_authenticator: nil, ) end end @@ -63,6 +79,7 @@ configuration_id: { configuration_not_found: true }, }, configuration_id:, + platform_authenticator: nil, ) end @@ -86,6 +103,7 @@ name: { blank: true }, }, configuration_id:, + platform_authenticator: false, ) end @@ -96,6 +114,16 @@ expect(configuration.reload.name).to eq(original_name) end + + context 'with platform authenticator' do + let(:configuration) do + create(:webauthn_configuration, :platform_authenticator, user:, name: original_name) + + it 'includes platform authenticator detail in result' do + expect(result.to_h[:platform_authenticator]).to eq(true) + end + end + end end context 'with duplicate name' do @@ -111,6 +139,7 @@ name: { duplicate: true }, }, configuration_id:, + platform_authenticator: false, ) end @@ -121,6 +150,16 @@ expect(configuration.reload.name).to eq(original_name) end + + context 'with platform authenticator' do + let(:configuration) do + create(:webauthn_configuration, :platform_authenticator, user:, name: original_name) + + it 'includes platform authenticator detail in result' do + expect(result.to_h[:platform_authenticator]).to eq(true) + end + end + end end end diff --git a/spec/forms/webauthn_setup_form_spec.rb b/spec/forms/webauthn_setup_form_spec.rb index e467c404ec0..284b5811446 100644 --- a/spec/forms/webauthn_setup_form_spec.rb +++ b/spec/forms/webauthn_setup_form_spec.rb @@ -246,6 +246,10 @@ end end context 'webauthn_platform' do + let(:params) do + super().merge(platform_authenticator: true, transports: 'internal,hybrid') + end + context 'with one platform authenticator with the same name' do let(:user) do user = create(:user) @@ -257,12 +261,7 @@ ) user end - let(:params) do - super().merge( - platform_authenticator: true, - transports: 'internal,hybrid', - ) - end + it 'adds a new platform device with the same existing name and appends a (1)' do result = subject.submit(protocol, params) expect(result.extra[:multi_factor_auth_method]).to eq 'webauthn_platform' @@ -276,37 +275,26 @@ end context 'with two existing platform authenticators one with the same name' do - let(:user) do - user = create(:user) - user.webauthn_configurations << create( - :webauthn_configuration, - name: device_name, - platform_authenticator: true, - transports: ['internal', 'hybrid'], - ) - user.webauthn_configurations << create( - :webauthn_configuration, - name: device_name, - platform_authenticator: true, - transports: ['internal', 'hybrid'], - ) - user - end - let(:params) do - super().merge( - platform_authenticator: true, - transports: 'internal,hybrid', + let!(:user) do + create( + :user, + webauthn_configurations: create_list( + :webauthn_configuration, + 2, + name: device_name, + platform_authenticator: true, + transports: ['internal', 'hybrid'], + ), ) end + it 'adds a second new platform device with the same existing name and appends a (2)' do result = subject.submit(protocol, params) - expect(result.extra[:multi_factor_auth_method]).to eq 'webauthn_platform' + + expect(result.success?).to eq(true) expect(user.webauthn_configurations.platform_authenticators.count).to eq(3) - expect( - user.webauthn_configurations.platform_authenticators[2].name, - ). + expect(user.webauthn_configurations.platform_authenticators.last.name). to eq("#{device_name} (2)") - expect(result.to_h[:success]).to eq(true) end end end diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb index aea5b92134e..d5648c3cb64 100644 --- a/spec/i18n_spec.rb +++ b/spec/i18n_spec.rb @@ -174,14 +174,6 @@ def allowed_untranslated_key?(locale, key) bad_keys = keys.reject { |key| key =~ /^[a-z0-9_.]+$/ } expect(bad_keys).to be_empty end - - it 'has only has XML-safe identifiers (keys start with a letter)' do - keys = flattened_yaml_data.keys - - bad_keys = keys.select { |key| key.split('.').any? { |part| part =~ /^[0-9]/ } } - - expect(bad_keys).to be_empty - end end it 'has correctly-formatted interpolation values' do diff --git a/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx b/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx index dd5a2467c9d..364ed89dafa 100644 --- a/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx +++ b/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx @@ -375,7 +375,6 @@ describe('document-capture/components/review-issues-step', () => { FeatureFlagContext.Provider, { value: { - notReadySectionEnabled: true, exitQuestionSectionEnabled: true, }, }, diff --git a/spec/javascript/packs/pw-strength-spec.js b/spec/javascript/packs/pw-strength-spec.js deleted file mode 100644 index b5b7700cbf0..00000000000 --- a/spec/javascript/packs/pw-strength-spec.js +++ /dev/null @@ -1,79 +0,0 @@ -import zxcvbn from 'zxcvbn'; -import { getForbiddenPasswords, getFeedback } from '../../../app/javascript/packs/pw-strength'; - -describe('pw-strength', () => { - describe('getForbiddenPasswords', () => { - it('returns empty array if given argument is null', () => { - const element = null; - const result = getForbiddenPasswords(element); - - expect(result).to.deep.equal([]); - }); - - it('returns empty array if element has absent dataset value', () => { - const element = document.createElement('span'); - const result = getForbiddenPasswords(element); - - expect(result).to.deep.equal([]); - }); - - it('returns empty array if element has invalid dataset value', () => { - const element = document.createElement('span'); - element.setAttribute('data-forbidden', 'nil'); - const result = getForbiddenPasswords(element); - - expect(result).to.deep.equal([]); - }); - - it('parsed array of forbidden passwords', () => { - const element = document.createElement('span'); - element.setAttribute('data-forbidden', '["foo","bar","baz"]'); - const result = getForbiddenPasswords(element); - - expect(result).to.be.deep.equal(['foo', 'bar', 'baz']); - }); - }); - - describe('getFeedback', () => { - const EMPTY_RESULT = ' '; - const MINIMUM_LENGTH = 12; - const FORBIDDEN_PASSWORDS = ['gsa', 'Login.gov']; - - it('returns an empty result for empty password', () => { - const z = zxcvbn(''); - - expect(getFeedback(z, MINIMUM_LENGTH, FORBIDDEN_PASSWORDS)).to.equal(EMPTY_RESULT); - }); - - it('returns an empty result for a strong password', () => { - const z = zxcvbn('!Juq2Uk2**RBEsA8'); - - expect(getFeedback(z, MINIMUM_LENGTH, FORBIDDEN_PASSWORDS)).to.equal(EMPTY_RESULT); - }); - - it('returns feedback for a weak password', () => { - const z = zxcvbn('password'); - - expect(getFeedback(z, MINIMUM_LENGTH, FORBIDDEN_PASSWORDS)).to.equal( - 'zxcvbn.feedback.this_is_a_top_10_common_password', - ); - }); - - it('shows feedback when a password is too short', () => { - const z = zxcvbn('_3G%JMyR"'); - - expect(getFeedback(z, MINIMUM_LENGTH, FORBIDDEN_PASSWORDS)).to.equal( - 'errors.attributes.password.too_short.other', - { count: MINIMUM_LENGTH }, - ); - }); - - it('shows feedback when a user enters a forbidden password', () => { - const z = zxcvbn('gsa'); - - expect(getFeedback(z, MINIMUM_LENGTH, FORBIDDEN_PASSWORDS)).to.equal( - 'errors.attributes.password.avoid_using_phrases_that_are_easily_guessed', - ); - }); - }); -}); diff --git a/spec/presenters/two_factor_authentication/webauthn_edit_presenter_spec.rb b/spec/presenters/two_factor_authentication/webauthn_edit_presenter_spec.rb new file mode 100644 index 00000000000..c52957eac15 --- /dev/null +++ b/spec/presenters/two_factor_authentication/webauthn_edit_presenter_spec.rb @@ -0,0 +1,103 @@ +require 'rails_helper' + +RSpec.describe TwoFactorAuthentication::WebauthnEditPresenter do + let(:configuration) { build(:webauthn_configuration) } + + subject(:presenter) { described_class.new(configuration:) } + + describe '#heading' do + subject(:heading) { presenter.heading } + + context 'with roaming authenticator' do + let(:configuration) { build(:webauthn_configuration) } + + it { expect(heading).to eq(t('two_factor_authentication.webauthn_roaming.edit_heading')) } + end + + context 'with platform authenticator' do + let(:configuration) { build(:webauthn_configuration, :platform_authenticator) } + + it { expect(heading).to eq(t('two_factor_authentication.webauthn_platform.edit_heading')) } + end + end + + describe '#nickname_field_label' do + subject(:heading) { presenter.nickname_field_label } + + context 'with roaming authenticator' do + let(:configuration) { build(:webauthn_configuration) } + + it { expect(heading).to eq(t('two_factor_authentication.webauthn_roaming.nickname')) } + end + + context 'with platform authenticator' do + let(:configuration) { build(:webauthn_configuration, :platform_authenticator) } + + it { expect(heading).to eq(t('two_factor_authentication.webauthn_platform.nickname')) } + end + end + + describe '#rename_button_label' do + subject(:heading) { presenter.rename_button_label } + + context 'with roaming authenticator' do + let(:configuration) { build(:webauthn_configuration) } + + it { expect(heading).to eq(t('two_factor_authentication.webauthn_roaming.change_nickname')) } + end + + context 'with platform authenticator' do + let(:configuration) { build(:webauthn_configuration, :platform_authenticator) } + + it { expect(heading).to eq(t('two_factor_authentication.webauthn_platform.change_nickname')) } + end + end + + describe '#delete_button_label' do + subject(:heading) { presenter.delete_button_label } + + context 'with roaming authenticator' do + let(:configuration) { build(:webauthn_configuration) } + + it { expect(heading).to eq(t('two_factor_authentication.webauthn_roaming.delete')) } + end + + context 'with platform authenticator' do + let(:configuration) { build(:webauthn_configuration, :platform_authenticator) } + + it { expect(heading).to eq(t('two_factor_authentication.webauthn_platform.delete')) } + end + end + + describe '#rename_success_alert_text' do + subject(:heading) { presenter.rename_success_alert_text } + + context 'with roaming authenticator' do + let(:configuration) { build(:webauthn_configuration) } + + it { expect(heading).to eq(t('two_factor_authentication.webauthn_roaming.renamed')) } + end + + context 'with platform authenticator' do + let(:configuration) { build(:webauthn_configuration, :platform_authenticator) } + + it { expect(heading).to eq(t('two_factor_authentication.webauthn_platform.renamed')) } + end + end + + describe '#delete_success_alert_text' do + subject(:heading) { presenter.delete_success_alert_text } + + context 'with roaming authenticator' do + let(:configuration) { build(:webauthn_configuration) } + + it { expect(heading).to eq(t('two_factor_authentication.webauthn_roaming.deleted')) } + end + + context 'with platform authenticator' do + let(:configuration) { build(:webauthn_configuration, :platform_authenticator) } + + it { expect(heading).to eq(t('two_factor_authentication.webauthn_platform.deleted')) } + end + end +end diff --git a/spec/services/push_notification/http_push_spec.rb b/spec/services/push_notification/http_push_spec.rb index c4d0cf79759..6e3e08b2c8b 100644 --- a/spec/services/push_notification/http_push_spec.rb +++ b/spec/services/push_notification/http_push_spec.rb @@ -5,8 +5,8 @@ let(:user) { create(:user) } - let(:sp_with_push_url) { create(:service_provider, push_notification_url: 'http://foo.bar/push') } - let(:sp_no_push_url) { create(:service_provider, push_notification_url: nil) } + let(:sp_with_push_url) { create(:service_provider, active: true, push_notification_url: 'http://foo.bar/push') } + let(:sp_no_push_url) { create(:service_provider, active: true, push_notification_url: nil) } let!(:sp_with_push_url_identity) do IdentityLinker.new(user, sp_with_push_url).link_identity @@ -128,7 +128,7 @@ end context 'with a timeout when posting to one url' do - let(:third_sp) { create(:service_provider, push_notification_url: 'http://sp.url/push') } + let(:third_sp) { create(:service_provider, active: true, push_notification_url: 'http://sp.url/push') } before do IdentityLinker.new(user, third_sp).link_identity @@ -182,6 +182,16 @@ end end + context 'when a service provider is no longer active' do + before { sp_with_push_url.update!(active: false) } + + it 'does not notify that SP' do + deliver + + expect(WebMock).not_to have_requested(:get, sp_with_push_url.push_notification_url) + end + end + context 'when a user has revoked access to an SP' do before do identity = user.identities.find_by(service_provider: sp_with_push_url.issuer) diff --git a/spec/views/accounts/_webauthn_roaming.html.erb_spec.rb b/spec/views/accounts/_webauthn_roaming.html.erb_spec.rb new file mode 100644 index 00000000000..27f4375a3db --- /dev/null +++ b/spec/views/accounts/_webauthn_roaming.html.erb_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +RSpec.describe 'accounts/_webauthn_roaming.html.erb' do + let(:user) do + create( + :user, + webauthn_configurations: create_list(:webauthn_configuration, 2), + ) + end + let(:user_session) { { auth_events: [] } } + + subject(:rendered) { render partial: 'accounts/webauthn_roaming' } + + before do + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:user_session).and_return(user_session) + end + + it 'renders a list of roaming authenticators' do + expect(rendered).to have_selector('[role="list"] [role="list-item"]', count: 2) + end +end diff --git a/spec/views/devise/shared/_password_strength.html.erb_spec.rb b/spec/views/devise/shared/_password_strength.html.erb_spec.rb deleted file mode 100644 index 7d04b321dc9..00000000000 --- a/spec/views/devise/shared/_password_strength.html.erb_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -require 'rails_helper' - -RSpec.describe 'devise/shared/_password_strength.html.erb' do - describe 'forbidden attributes' do - context 'when local is unassigned' do - before do - render - end - - it 'omits data-forbidden attribute from strength text tag' do - expect(rendered).to have_selector('#pw-strength-txt:not([data-forbidden])') - end - end - - context 'when local is nil' do - before do - render 'devise/shared/password_strength', forbidden_passwords: nil - end - - it 'omits data-forbidden attribute from strength text tag' do - expect(rendered).to have_selector('#pw-strength-txt:not([data-forbidden])') - end - end - - context 'when local is assigned' do - before do - render 'devise/shared/password_strength', forbidden_passwords: ['a', 'b', 'c'] - end - - it 'adds JSON-encoded data-forbidden to strength text tag' do - expect(rendered).to have_selector('#pw-strength-txt[data-forbidden="[\"a\",\"b\",\"c\"]"]') - end - end - end -end diff --git a/spec/views/users/webauthn/edit.html.erb_spec.rb b/spec/views/users/webauthn/edit.html.erb_spec.rb index d5bbc9fb16a..30785d7e71a 100644 --- a/spec/views/users/webauthn/edit.html.erb_spec.rb +++ b/spec/views/users/webauthn/edit.html.erb_spec.rb @@ -6,6 +6,7 @@ let(:nickname) { 'Example' } let(:configuration) { create(:webauthn_configuration, :platform_authenticator, name: nickname) } let(:user) { create(:user, webauthn_configurations: [configuration]) } + let(:presenter) { TwoFactorAuthentication::WebauthnEditPresenter.new(configuration:) } let(:form) do TwoFactorAuthentication::WebauthnUpdateForm.new( user:, @@ -17,6 +18,7 @@ before do @form = form + @presenter = presenter end it 'renders form to update configuration' do diff --git a/tsconfig.json b/tsconfig.json index 1c56906d480..e697d8bc798 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,10 +25,5 @@ "./*.js", "scripts" ], - "exclude": [ - "**/fixtures", - "**/*.spec.js", - "app/javascript/packs/pw-strength.js", - "app/javascript/packs/saml-post.js" - ] + "exclude": ["**/fixtures", "**/*.spec.js", "app/javascript/packs/saml-post.js"] } diff --git a/yarn.lock b/yarn.lock index 24a103d9ec2..5ca9b3b21f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1684,6 +1684,11 @@ dependencies: "@types/yargs-parser" "*" +"@types/zxcvbn@^4.4.4": + version "4.4.4" + resolved "https://registry.yarnpkg.com/@types/zxcvbn/-/zxcvbn-4.4.4.tgz#987f5fcd87e957097433c476c3a1c91a54f53131" + integrity sha512-Tuk4q7q0DnpzyJDI4aMeghGuFu2iS1QAdKpabn8JfbtfGmVDUgvZv1I7mEjP61Bvnp3ljKCC8BE6YYSTNxmvRQ== + "@typescript-eslint/eslint-plugin@^6.7.5": version "6.7.5" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.7.5.tgz#f4024b9f63593d0c2b5bd6e4ca027e6f30934d4f" @@ -4650,10 +4655,10 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -libphonenumber-js@^1.10.53: - version "1.10.53" - resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.53.tgz#8dbfe1355ef1a3d8e13b8d92849f7db7ebddc98f" - integrity sha512-sDTnnqlWK4vH4AlDQuswz3n4Hx7bIQWTpIcScJX+Sp7St3LXHmfiax/ZFfyYxHmkdCvydOLSuvtAO/XpXiSySw== +libphonenumber-js@^1.10.54: + version "1.10.54" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.54.tgz#8dfba112f49d1b9c2a160e55f9697f22e50f0841" + integrity sha512-P+38dUgJsmh0gzoRDoM4F5jLbyfztkU6PY6eSK6S5HwTi/LPvnwXqVCQZlAy1FxZ5c48q25QhxGQ0pq+WQcSlQ== lightningcss-darwin-arm64@1.22.0: version "1.22.0" @@ -5980,50 +5985,90 @@ safe-regex-test@^1.0.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sass-embedded-darwin-arm64@1.69.2: - version "1.69.2" - resolved "https://registry.yarnpkg.com/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.69.2.tgz#43acf549b5ca753a0fc32dc504e8c2b007c5a503" - integrity sha512-3e/tRdLTMlmJ45g0vKRDgJJi5P3DO6eS/L7mS89QQcGSTWI5hIS4Yk3K2KkxGwH8QqjkUHAqrVuaD//eBjkGdA== - -sass-embedded-darwin-x64@1.69.2: - version "1.69.2" - resolved "https://registry.yarnpkg.com/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.69.2.tgz#703e59cae6511f9b8f9ed5fbaf625dc708ebbab2" - integrity sha512-kJAqg/0fzJMlJY6lzUaWdkzw7P7HJpIXPgxZ8JTCcYM2Xnkt0/kOMMk3a6xwh5m9uNmCNyd18xaau7BnItiVLw== - -sass-embedded-linux-arm64@1.69.2: - version "1.69.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.69.2.tgz#91756876417e1b1637226dc3f006f05f1aa8dcd4" - integrity sha512-WoNDh/nJ3XNayzSDV7GLOEnW1X6x1zgOBqiXTRQDtQ/Vlf8ydvASUsdcQ4xYDnGlPPkpNgG7XhQjsk1oPSi3Kg== - -sass-embedded-linux-arm@1.69.2: - version "1.69.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.69.2.tgz#93156cb92797351e4f3a2386df040ceca17a7600" - integrity sha512-U/n1qL516VfCkMZ4nuNhbORkyvmoyirloSWECuG07L5/irNr0OlAJZ1gmAoLFaZvTLNC5jepLSh18E5N2yvcOQ== - -sass-embedded-linux-ia32@1.69.2: - version "1.69.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.69.2.tgz#990976b81846493e7948b18131687c1b83bcfabb" - integrity sha512-94/3sR1xuIWBX/UqnYLBhxS7Xx6mn0iuVuxQBHv3emdFKCLLrUhUBPT3CaBJPcRtzpeRf9docBBNEbJbk92hyQ== - -sass-embedded-linux-x64@1.69.2: - version "1.69.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.69.2.tgz#2baf19761d28bc1c9fea680c3375e00ea18c5e13" - integrity sha512-0rgdwkVBCNKnmlxHHs5DSp8WzicYXAdFGy5KkpCUngRuDZt8P3bwXD7j0fwLOZx83nbaAI54PmZVUDQBODkvMg== - -sass-embedded-win32-ia32@1.69.2: - version "1.69.2" - resolved "https://registry.yarnpkg.com/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.69.2.tgz#a24ae3f696efef6418ae44a6af2e8fd2eb0ec658" - integrity sha512-dBV8Gb9EvqZ4R7VgpYGXaPcqsDDHWznvnY7Cenp6Ub9ookq9X+wXp86JGifQSeisC/sYQPiJafvPrbLZKz4lLQ== - -sass-embedded-win32-x64@1.69.2: - version "1.69.2" - resolved "https://registry.yarnpkg.com/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.69.2.tgz#281906403c3ee87d63909542220eda864318c313" - integrity sha512-TqsJajpboz/qY96R38KF/XSWlt/sdZ+O5Nnkm0os5rbBw3Rv8j/80C8eQ30T4BNinLPrsyBWfH5xJw+Cl9mpWA== - -sass-embedded@^1.69.2: - version "1.69.2" - resolved "https://registry.yarnpkg.com/sass-embedded/-/sass-embedded-1.69.2.tgz#3d68cf30da4c14b5b2c009d8cb47f9a245c65c6d" - integrity sha512-B6vRMgpKkWagflo57FXvrpWxizQDJwCB7vquV3WVXzGsEWxRIX4CUWNR/Mq6lMohnkzuUb3ctW54Zrt/716l9Q== +sass-embedded-android-arm64@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.70.0.tgz#3bdc0591239a0c4c45313e949883a87bb37e07a2" + integrity sha512-vMr7fruLUv/VvF7CPVF1z7Bc28a8K9Ps5nyN3UatOj+irxN1LbZIbeQua6neX2eFUsXvcg7hLZwvV3+T96Fhrw== + +sass-embedded-android-arm@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-android-arm/-/sass-embedded-android-arm-1.70.0.tgz#e003444e41e1ac2f85cbaa662a7e39df510286e6" + integrity sha512-Vog4Z+tsDYGv7m9sZisr/P6KvqDioCMu0cinexdnXhHXReo+X6CFe79yv/zA/Xfq5HtAAmFjGD6CO/nTjoydtw== + +sass-embedded-android-ia32@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.70.0.tgz#3051a973b902be3ec66a2b24549273e23484b08f" + integrity sha512-RWEJ7sBGBCd101oSBPuePPU8yXb1iB/ME4sRhgI5xjjyIsldiuvX48saW25u1ZqCo2AVA0BTXfWpNJnhKB3b4Q== + +sass-embedded-android-x64@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-android-x64/-/sass-embedded-android-x64-1.70.0.tgz#86f4e7d1c91d35443cb9c06566e9a61efb4cf6e1" + integrity sha512-u+ijV6AQR/84kjjGb3mp0aibPiXkFKqfmHxqYBMN7h2xV7EM70Yz054nVifaBr8nfC0E8aT/DurSI4nkkQ6Uvg== + +sass-embedded-darwin-arm64@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.70.0.tgz#6fca5c9925e70b85ae2008b5cb9d5359c7f0b80d" + integrity sha512-qMs08h0nwRA1B/Ieakcg/Y6lcCEnuBnPTNEkFkBlnfj3PFVPTb50wQvDr9JLpcjXWznlBxyFrz1nZM+pXDix7Q== + +sass-embedded-darwin-x64@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.70.0.tgz#c618be025ddc47515f4cb7e25c71ac1cb21551c2" + integrity sha512-Vf8UQY3IBmsaz9L5DeJDjn19N//1n3rTquH69x29zPCd3zF2gnay38atxIZ+6h7VsZT3C6evm0y58JUJDWN1CA== + +sass-embedded-linux-arm64@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.70.0.tgz#24ca8d8ed74611cb9f9efbf76ecd65908387a99e" + integrity sha512-PzhBg5xlyXcZ8FgyjqAcVtfaq462l3KeEid2OxrsOzBQgdgJb0La1tAEOpP9jz7YOOTr9A96vm609W9fRLI2Iw== + +sass-embedded-linux-arm@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.70.0.tgz#017085c42a2060e36f629c22f9daaa5491dd866f" + integrity sha512-U9e+k0XHwubeSIwsBYTNrTVH+0zF/ErSfuHfgTfuvlcKlhoGtFgAb7W8Qfe9FDF6TYTt0fJAJhSV2MdoExsgRA== + +sass-embedded-linux-ia32@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.70.0.tgz#cf6bc32beda3c8fc8c6886fb1b796de925c5f201" + integrity sha512-UOxTJywQRC/HzFQthlyNWJ07MX8EzKuTgH0N5T3XyXQTNuGeJQ8EPWY9fv1weLCjydVOEwm853F3djtUNmkCtg== + +sass-embedded-linux-musl-arm64@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.70.0.tgz#cc81f56d2e14cdf412133a5ae2ed9d62f5b1dcac" + integrity sha512-DJl1AV9W7T3SHzXFqAtyjPZy4O2g4AC6QctY5/aM42DTY/xpWOmwUBgsDzDoRbNqP7qDl+GtHLlggrLWCBP9fg== + +sass-embedded-linux-musl-arm@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.70.0.tgz#4e22f7dc127b6920a784b0acb9a59efb465f1d1f" + integrity sha512-8zudDFpAoNrQDujNYBKkq8nwl4i0jMmXcysO9Ou0llrzdY7Keok2z1aS3IbZy7AvUXtGaeYSHUi5lXdOalJ/QQ== + +sass-embedded-linux-musl-ia32@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.70.0.tgz#c0a7278d542870a8114134b9bd1829d6fd16828e" + integrity sha512-CcAvT3KPc7cCJfTu1E0HzsAjE/dPQsKaXQD/nsBXNZo081R+lLR2u22wpXM2pnzMNJETRV/pDwozHoYEcPkPqQ== + +sass-embedded-linux-musl-x64@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.70.0.tgz#5c0710733cf98b309e72f82b6b17bf53f3c9b230" + integrity sha512-g3i9PKmqTxuyrM1Yeju1s4Fj6fzAGyyfzw/LiZZtq0ZZGhJXJMVvEDog/OxQ37eYxWqq9XHFTW2PphMvukVK0g== + +sass-embedded-linux-x64@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.70.0.tgz#1ceae4677f7dc9052727d08aa6f54dee2cf6c0b2" + integrity sha512-F9F2CA7C6z/ROfF0U/jtYWknbDe9S/TJoCJ5TlHafwS+SrZE1A+Czf2MWJ+8mc2NFiRjYzYxt4Ad29cuc6rrhw== + +sass-embedded-win32-ia32@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.70.0.tgz#b67e4a66548deca91eb75a9c467d40b1ad080e95" + integrity sha512-TITx2QwJouhMwA0CAjCmnTNeCDL9g2fkLe9z+5rf39OdmcX9CEBrY4CNaO5REnMpgoa+o82u272ZR3oWrsUs8Q== + +sass-embedded-win32-x64@1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.70.0.tgz#5a07423cf1370c302e6d44105358f6591b675b1e" + integrity sha512-rPe8WUdARhlfgIhGcCTGbTNgd6OppcmjtBrxUNoGs3AENSREQCpaNv5d+HBOMhGUfYgXIHUSiipilFUhLWpsrQ== + +sass-embedded@^1.70.0: + version "1.70.0" + resolved "https://registry.yarnpkg.com/sass-embedded/-/sass-embedded-1.70.0.tgz#558f5e9776c6e4b91d9859a4dd325ac7c2b91391" + integrity sha512-1sVSh5MlSdktkwC2zG9WuaVR6j7AlDxadPmZBN0wP4GhznMQTvpwNIAFhAqgjwJYhwdWFOKEdIHSQK4V8K434Q== dependencies: "@bufbuild/protobuf" "^1.0.0" buffer-builder "^0.2.0" @@ -6032,14 +6077,22 @@ sass-embedded@^1.69.2: supports-color "^8.1.1" varint "^6.0.0" optionalDependencies: - sass-embedded-darwin-arm64 "1.69.2" - sass-embedded-darwin-x64 "1.69.2" - sass-embedded-linux-arm "1.69.2" - sass-embedded-linux-arm64 "1.69.2" - sass-embedded-linux-ia32 "1.69.2" - sass-embedded-linux-x64 "1.69.2" - sass-embedded-win32-ia32 "1.69.2" - sass-embedded-win32-x64 "1.69.2" + sass-embedded-android-arm "1.70.0" + sass-embedded-android-arm64 "1.70.0" + sass-embedded-android-ia32 "1.70.0" + sass-embedded-android-x64 "1.70.0" + sass-embedded-darwin-arm64 "1.70.0" + sass-embedded-darwin-x64 "1.70.0" + sass-embedded-linux-arm "1.70.0" + sass-embedded-linux-arm64 "1.70.0" + sass-embedded-linux-ia32 "1.70.0" + sass-embedded-linux-musl-arm "1.70.0" + sass-embedded-linux-musl-arm64 "1.70.0" + sass-embedded-linux-musl-ia32 "1.70.0" + sass-embedded-linux-musl-x64 "1.70.0" + sass-embedded-linux-x64 "1.70.0" + sass-embedded-win32-ia32 "1.70.0" + sass-embedded-win32-x64 "1.70.0" saxes@^6.0.0: version "6.0.0" @@ -7315,7 +7368,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zxcvbn@4.4.2: +zxcvbn@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/zxcvbn/-/zxcvbn-4.4.2.tgz#28ec17cf09743edcab056ddd8b1b06262cc73c30" integrity sha1-KOwXzwl0PtyrBW3dixsGJizHPDA=