Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,11 @@ Rails/SelectMap:
Rails/ShortI18n:
Enabled: true

Rails/SkipsModelValidations:
Enabled: true
Exclude:
- 'spec/**/*.rb'

Rails/StripHeredoc:
Enabled: false

Expand Down
3 changes: 3 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ def after_sign_in_path_for(_user)
return login_add_piv_cac_prompt_url if session[:needs_to_setup_piv_cac_after_sign_in].present?
return reactivate_account_url if user_needs_to_reactivate_account?
return login_piv_cac_recommended_path if user_recommended_for_piv_cac?
return webauthn_platform_recommended_path if recommend_webauthn_platform_for_sms_user?(
:recommend_for_authentication,
)
return second_mfa_reminder_url if user_needs_second_mfa_reminder?
return sp_session_request_url_with_updated_params if sp_session.key?(:request_url)
signed_in_url
Expand Down
14 changes: 9 additions & 5 deletions app/controllers/concerns/mfa_setup_concern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

module MfaSetupConcern
extend ActiveSupport::Concern
include RecommendWebauthnPlatformConcern

def next_setup_path
if suggest_second_mfa?
auth_method_confirmation_url
elsif next_setup_choice
if next_setup_choice
confirmation_path
elsif recommend_webauthn_platform_for_sms_user?(:recommend_for_account_creation)
webauthn_platform_recommended_path
elsif suggest_second_mfa?
auth_method_confirmation_path
elsif user_session[:mfa_selections]
track_user_registration_mfa_setup_complete_event
user_session.delete(:mfa_selections)
Expand Down Expand Up @@ -52,8 +55,9 @@ def mfa_context
end

def suggest_second_mfa?
return false unless user_session[:mfa_selections]
mfa_selection_count < 2 && mfa_context.enabled_mfa_methods_count < 2
return false if !in_multi_mfa_selection_flow?
return false if current_user.webauthn_platform_recommended_dismissed_at?
mfa_context.enabled_mfa_methods_count < 2
end

def first_mfa_selection_path
Expand Down
39 changes: 39 additions & 0 deletions app/controllers/concerns/recommend_webauthn_platform_concern.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

module RecommendWebauthnPlatformConcern
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice like having this split out

def recommend_webauthn_platform_for_sms_user?(bucket)
# Only consider for A/B test if:
# 1. Option would be offered for setup
# 2. User is viewing content in English
# 3. Other recommendations have not already been offered (e.g. PIV/CAC for federal emails)
# 4. User selected to setup phone or authenticated with phone
# 5. User has not already set up a platform authenticator
return false if !device_supports_platform_authenticator_setup?
return false if I18n.locale != :en
return false if current_user.webauthn_platform_recommended_dismissed_at?
return false if !user_set_up_or_authenticated_with_phone?
return false if current_user.webauthn_configurations.platform_authenticators.present?
ab_test_bucket(:RECOMMEND_WEBAUTHN_PLATFORM_FOR_SMS_USER) == bucket
end

private

def device_supports_platform_authenticator_setup?
user_session[:platform_authenticator_available] == true
end

def in_account_creation_flow?
user_session[:in_account_creation_flow] == true
end

def user_set_up_or_authenticated_with_phone?
if in_account_creation_flow?
current_user.phone_configurations.any? do |phone_configuration|
phone_configuration.mfa_enabled? && phone_configuration.delivery_preference == 'sms'
end
else
auth_methods_session.auth_events.pluck(:auth_method).
include?(TwoFactorAuthenticatable::AuthMethod::SMS)
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module Users
class TwoFactorAuthenticationSetupController < ApplicationController
include UserAuthenticator
include MfaSetupConcern
include AbTestingConcern

before_action :authenticate_user
before_action :confirm_user_authenticated_for_2fa_setup
Expand All @@ -23,6 +24,8 @@ def index
def create
result = submit_form
analytics.user_registration_2fa_setup(**result.to_h)
user_session[:platform_authenticator_available] =
params[:platform_authenticator_available] == 'true'

if result.success?
process_valid_form
Expand Down
38 changes: 38 additions & 0 deletions app/controllers/users/webauthn_platform_recommended_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

module Users
class WebauthnPlatformRecommendedController < ApplicationController
include SecureHeadersConcern
include MfaSetupConcern

before_action :confirm_two_factor_authenticated
before_action :apply_secure_headers_override

def new
@sign_in_flow = session[:sign_in_flow]
analytics.webauthn_platform_recommended_visited
end

def create
analytics.webauthn_platform_recommended_submitted(opted_to_add: opted_to_add?)
current_user.update(webauthn_platform_recommended_dismissed_at: Time.zone.now)
redirect_to dismiss_redirect_path
end

private

def opted_to_add?
params[:add_method].present?
end

def dismiss_redirect_path
if opted_to_add?
webauthn_setup_path(platform: true)
elsif in_account_creation_flow?
next_setup_path
else
after_sign_in_path_for(current_user)
end
end
end
end
17 changes: 6 additions & 11 deletions app/javascript/packs/platform-authenticator-available.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,13 @@ import {
isWebauthnPasskeySupported,
} from '@18f/identity-webauthn';

async function platformAuthenticatorAvailable() {
const platformAuthenticatorAvailableInput = document.getElementById(
'platform_authenticator_available',
) as HTMLInputElement;
if (!platformAuthenticatorAvailableInput) {
return;
}
export async function initialize() {
const input = document.getElementById('platform_authenticator_available') as HTMLInputElement;
if (isWebauthnPasskeySupported() && (await isWebauthnPlatformAuthenticatorAvailable())) {
platformAuthenticatorAvailableInput.value = 'true';
} else {
platformAuthenticatorAvailableInput.value = 'false';
input.value = 'true';
}
}

platformAuthenticatorAvailable();
if (process.env.NODE_ENV !== 'test') {
initialize();
}
11 changes: 11 additions & 0 deletions app/services/analytics_events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7062,6 +7062,17 @@ def webauthn_delete_submitted(
)
end

# User submits WebAuthn platform authenticator recommended screen
# @param [Boolean] opted_to_add Whether the user chose to add a method
def webauthn_platform_recommended_submitted(opted_to_add:, **extra)
track_event(:webauthn_platform_recommended_submitted, opted_to_add:, **extra)
end

# User visits WebAuthn platform authenticator recommended screen
def webauthn_platform_recommended_visited
track_event(:webauthn_platform_recommended_visited)
end

# @param [Hash] platform_authenticator
# @param [Boolean] success
# @param [Hash, nil] errors
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
</fieldset>
</div>

<%= hidden_field_tag :platform_authenticator_available, id: 'platform_authenticator_available' %>
<% javascript_packs_tag_once('platform-authenticator-available') %>

<%= f.submit t('forms.buttons.continue'), class: 'margin-bottom-1' %>
<% end %>

Expand Down
41 changes: 41 additions & 0 deletions app/views/users/webauthn_platform_recommended/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<% self.title = t('webauthn_platform_recommended.heading') %>

<%= render StatusPageComponent.new(status: :info, icon: :question) do |c| %>
<% c.with_header { t('webauthn_platform_recommended.heading') } %>

<p><%= t('webauthn_platform_recommended.description_secure_account') %></p>

<p>
<%= t(
'webauthn_platform_recommended.description_private_html',
phishing_resistant_link_html: new_tab_link_to(
t('webauthn_platform_recommended.phishing_resistant'),
help_center_redirect_path(
category: 'get-started',
article: 'authentication-methods',
anchor: 'face-or-touch-unlock',
flow: @sign_in_flow,
step: :webauthn_platform_recommended,
),
),
) %>
</p>

<div class="grid-row margin-top-5">
<div class="tablet:grid-col-9">
<%= render ButtonComponent.new(
url: webauthn_platform_recommended_url,
method: :post,
params: { add_method: true },
big: true,
full_width: true,
class: 'margin-bottom-2',
).with_content(t('webauthn_platform_recommended.cta')) %>
<%= render ButtonComponent.new(
url: webauthn_platform_recommended_url,
method: :post,
unstyled: true,
).with_content(t('webauthn_platform_recommended.skip')) %>
</div>
</div>
<% end %>
2 changes: 2 additions & 0 deletions config/application.yml.default
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ recaptcha_enterprise_project_id: ''
recaptcha_mock_validator: true
recaptcha_secret_key: ''
recaptcha_site_key: ''
recommend_webauthn_platform_for_sms_ab_test_account_creation_percent: 0
recommend_webauthn_platform_for_sms_ab_test_authentication_percent: 0
recovery_code_length: 4
redis_pool_size: 10
redis_throttle_pool_size: 5
Expand Down
15 changes: 15 additions & 0 deletions config/initializers/ab_tests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,19 @@ def self.all
should_log: ['Password Reset: Password Submitted'].to_set,
buckets: { log: IdentityConfig.store.log_password_reset_matches_existing_ab_test_percent },
).freeze

RECOMMEND_WEBAUTHN_PLATFORM_FOR_SMS_USER = AbTest.new(
experiment_name: 'Recommend Face or Touch Unlock for SMS users',
should_log: [
'Multi-Factor Authentication',
'User Registration: MFA Setup Complete',
'User Registration: 2FA Setup',
].to_set,
buckets: {
recommend_for_account_creation:
IdentityConfig.store.recommend_webauthn_platform_for_sms_ab_test_account_creation_percent,
recommend_for_authentication:
IdentityConfig.store.recommend_webauthn_platform_for_sms_ab_test_authentication_percent,
Comment on lines +99 to +102
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting that having separate configurations per buckets makes local testing a little cumbersome, since they can't both be maxed out at the same time, otherwise initialization fails with "buckets exceeding 100". The solution is to set one or the other to 100 at a time and test each individually. In production, we'd have a smaller percentage, and using buckets this way allows for test candidates which are mutually exclusive from each other, which isn't strictly a requirement, but also not an issue.

},
).freeze
end
6 changes: 6 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2007,6 +2007,12 @@ vendor_outage.blocked.phone.default: We cannot verify phones at this time. Pleas
vendor_outage.get_updates: Get updates
vendor_outage.get_updates_on_status_page: Get updates on our status page
vendor_outage.working: We are working to resolve an error
webauthn_platform_recommended.cta: Set up face or touch unlock
webauthn_platform_recommended.description_private_html: Face or touch unlock is %{phishing_resistant_link_html} and we don’t store any recordings of your face or fingerprint, so your information stays private.
webauthn_platform_recommended.description_secure_account: Secure your sign in with face or touch unlock. Use your face, fingerprint, password, or another method to keep your account safer.
webauthn_platform_recommended.heading: Set up face or touch unlock for a more secure sign in
webauthn_platform_recommended.phishing_resistant: phishing-resistant
webauthn_platform_recommended.skip: Skip
zxcvbn.feedback.a_word_by_itself_is_easy_to_guess: A word by itself is easy to guess
zxcvbn.feedback.add_another_word_or_two_uncommon_words_are_better: Add another word or two. Uncommon words are better
zxcvbn.feedback.all_uppercase_is_almost_as_easy_to_guess_as_all_lowercase: All-uppercase is almost as easy to guess as all-lowercase
Expand Down
6 changes: 6 additions & 0 deletions config/locales/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2019,6 +2019,12 @@ vendor_outage.blocked.phone.default: No podemos verificar teléfonos en estos mo
vendor_outage.get_updates: Obtenga actualizaciones
vendor_outage.get_updates_on_status_page: Obtenga las actualizaciones en nuestra página de estado
vendor_outage.working: Estamos trabajando para corregir un error
webauthn_platform_recommended.cta: Set up face or touch unlock
webauthn_platform_recommended.description_private_html: Face or touch unlock is %{phishing_resistant_link_html} and we don’t store any recordings of your face or fingerprint, so your information stays private.
webauthn_platform_recommended.description_secure_account: Secure your sign in with face or touch unlock. Use your face, fingerprint, password, or another method to keep your account safer.
webauthn_platform_recommended.heading: Set up face or touch unlock for a more secure sign in
webauthn_platform_recommended.phishing_resistant: phishing-resistant
webauthn_platform_recommended.skip: Skip
zxcvbn.feedback.a_word_by_itself_is_easy_to_guess: Una sola palabra es fácil de adivinar.
zxcvbn.feedback.add_another_word_or_two_uncommon_words_are_better: Añada otra palabra o dos. Es mejor usar palabras poco comunes.
zxcvbn.feedback.all_uppercase_is_almost_as_easy_to_guess_as_all_lowercase: Todo en mayúsculas es casi tan fácil de adivinar como todo en minúsculas.
Expand Down
6 changes: 6 additions & 0 deletions config/locales/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2007,6 +2007,12 @@ vendor_outage.blocked.phone.default: Nous ne sommes pas actuellement en mesure d
vendor_outage.get_updates: Obtenir les dernières informations
vendor_outage.get_updates_on_status_page: Obtenir les dernières informations sur notre page d’état des systèmes.
vendor_outage.working: Nous travaillons à la résolution d’une erreur
webauthn_platform_recommended.cta: Set up face or touch unlock
webauthn_platform_recommended.description_private_html: Face or touch unlock is %{phishing_resistant_link_html} and we don’t store any recordings of your face or fingerprint, so your information stays private.
webauthn_platform_recommended.description_secure_account: Secure your sign in with face or touch unlock. Use your face, fingerprint, password, or another method to keep your account safer.
webauthn_platform_recommended.heading: Set up face or touch unlock for a more secure sign in
webauthn_platform_recommended.phishing_resistant: phishing-resistant
webauthn_platform_recommended.skip: Skip
zxcvbn.feedback.a_word_by_itself_is_easy_to_guess: Un mot seul est facile à deviner
zxcvbn.feedback.add_another_word_or_two_uncommon_words_are_better: Ajoutez un ou deux autres mots. Il est préférable d’utiliser des mots peu communs.
zxcvbn.feedback.all_uppercase_is_almost_as_easy_to_guess_as_all_lowercase: Tout en majuscules est presque aussi facile à deviner que tout en minuscules.
Expand Down
6 changes: 6 additions & 0 deletions config/locales/zh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2020,6 +2020,12 @@ vendor_outage.blocked.phone.default: 我们目前无法验证电话。请稍后
vendor_outage.get_updates: 获得最新信息
vendor_outage.get_updates_on_status_page: 在我们的状态页面获得最新信息。
vendor_outage.working: 我们正在争取解决错误。
webauthn_platform_recommended.cta: Set up face or touch unlock
webauthn_platform_recommended.description_private_html: Face or touch unlock is %{phishing_resistant_link_html} and we don’t store any recordings of your face or fingerprint, so your information stays private.
webauthn_platform_recommended.description_secure_account: Secure your sign in with face or touch unlock. Use your face, fingerprint, password, or another method to keep your account safer.
webauthn_platform_recommended.heading: Set up face or touch unlock for a more secure sign in
webauthn_platform_recommended.phishing_resistant: phishing-resistant
webauthn_platform_recommended.skip: Skip
zxcvbn.feedback.a_word_by_itself_is_easy_to_guess: 单字容易被人猜出
zxcvbn.feedback.add_another_word_or_two_uncommon_words_are_better: 再加一两个字不常见的字更好
zxcvbn.feedback.all_uppercase_is_almost_as_easy_to_guess_as_all_lowercase: 都是大写几乎和都是小写一样容易被人猜出
Expand Down
3 changes: 3 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@
get '/second_mfa_reminder' => 'users/second_mfa_reminder#new'
post '/second_mfa_reminder' => 'users/second_mfa_reminder#create'

get '/webauthn_platform_recommended' => 'users/webauthn_platform_recommended#new'
post '/webauthn_platform_recommended' => 'users/webauthn_platform_recommended#create'

get '/piv_cac' => 'users/piv_cac_authentication_setup#new', as: :setup_piv_cac
get '/piv_cac_error' => 'users/piv_cac_authentication_setup#error', as: :setup_piv_cac_error
post '/present_piv_cac' => 'users/piv_cac_authentication_setup#submit_new_piv_cac',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddWebauthnPlatformRecommendedDismissedAtToUser < ActiveRecord::Migration[7.2]
def change
add_column :users, :webauthn_platform_recommended_dismissed_at, :datetime, default: nil, comment: 'sensitive=false'
end
end
1 change: 1 addition & 0 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@
t.datetime "piv_cac_recommended_dismissed_at", comment: "sensitive=false"
t.datetime "sign_in_new_device_at", comment: "sensitive=false"
t.datetime "password_compromised_checked_at", comment: "sensitive=false"
t.datetime "webauthn_platform_recommended_dismissed_at", comment: "sensitive=false"
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
t.index ["sign_in_new_device_at"], name: "index_users_on_sign_in_new_device_at"
t.index ["uuid"], name: "index_users_on_uuid", unique: true
Expand Down
4 changes: 4 additions & 0 deletions lib/identity_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def self.store

# identity-hostdata transforms these configs to the described type
# rubocop:disable Metrics/BlockLength
# rubocop:disable Metrics/LineLength
BUILDER = proc do |config|
# ______________________________________
# / Adding something new in here? Please \
Expand Down Expand Up @@ -399,6 +400,8 @@ def self.store
config.add(:sign_in_recaptcha_percent_tested, type: :integer)
config.add(:sign_in_recaptcha_score_threshold, type: :float)
config.add(:skip_encryption_allowed_list, type: :json)
config.add(:recommend_webauthn_platform_for_sms_ab_test_account_creation_percent, type: :integer)
config.add(:recommend_webauthn_platform_for_sms_ab_test_authentication_percent, type: :integer)
config.add(:socure_document_request_endpoint, type: :string)
config.add(:socure_enabled, type: :boolean)
config.add(:socure_idplus_api_key, type: :string)
Expand Down Expand Up @@ -465,5 +468,6 @@ def self.store
config.add(:vtm_url)
config.add(:weekly_auth_funnel_report_config, type: :json)
end.freeze
# rubocop:enable Metrics/LineLength
# rubocop:enable Metrics/BlockLength
end
Loading