Skip to content
Draft
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
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ gem "rack-attack", "~> 6.8"
gem "rqrcode", "~> 3.1"
gem "rotp", "~> 6.2"
gem "unpwn", "~> 1.0"
gem "pwned", "~> 2.4"
gem "webauthn", "~> 3.4"
gem "browser", "~> 6.2"
gem "bcrypt", "~> 3.1"
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1006,6 +1006,7 @@ DEPENDENCIES
puma (~> 6.6)
puma-plugin-statsd (~> 2.7)
pundit (~> 2.5)
pwned (~> 2.4)
rack (~> 3.2)
rack-attack (~> 6.8)
rack-sanitizer (~> 2.0)
Expand Down
22 changes: 22 additions & 0 deletions app/controllers/compromised_passwords_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
class CompromisedPasswordsController < ApplicationController
layout "hammy"

before_action :validate_session

def show
@user = User.find_by(id: session[:compromised_password_user_id])
return redirect_to sign_in_path unless @user

# Send password reset email if not already sent
return if session[:compromised_password_email_sent]
@user.forgot_password!
PasswordMailer.change_password(@user).deliver_later
session[:compromised_password_email_sent] = true
end

private

def validate_session
redirect_to sign_in_path if session[:compromised_password_user_id].blank?
end
end
2 changes: 1 addition & 1 deletion app/controllers/concerns/require_mfa.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def prompt_mfa(alert: nil, status: :ok)
@otp_verification_url = otp_verification_url
setup_webauthn_authentication form_url: webauthn_verification_url
flash.now.alert = alert if alert
render template: "multifactor_auths/prompt", status:
render template: "multifactor_auths/prompt", layout: "hammy", status:
end

def otp_param
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/email_confirmations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def token_params

def login_failure(message)
flash.now.alert = message
render template: "multifactor_auths/prompt", status: :unauthorized
render template: "multifactor_auths/prompt", layout: "hammy", status: :unauthorized
end

def otp_verification_url
Expand Down
40 changes: 39 additions & 1 deletion app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ class SessionsController < Clearance::SessionsController
include WebauthnVerifiable
include SessionVerifiable

layout "hammy", only: %i[new create webauthn_full_create webauthn_create otp_create]

before_action :redirect_to_signin, unless: :signed_in?, only: %i[verify webauthn_authenticate authenticate]
before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?, only: %i[verify webauthn_authenticate authenticate]
before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled?, only: %i[verify webauthn_authenticate authenticate]
before_action :webauthn_new_setup, only: :new

before_action :ensure_not_blocked, only: %i[create]
before_action :find_user, only: %i[create]
before_action :check_password_compromised, only: %i[create]
before_action :require_mfa, only: %i[create]
before_action :find_mfa_user, only: %i[webauthn_create otp_create]
before_action :validate_otp, only: %i[otp_create]
Expand Down Expand Up @@ -151,7 +154,10 @@ def set_login_flash
end

def url_after_create
if current_user.mfa_recommended_not_yet_enabled?
if session.delete(:password_compromised)
session[:compromised_password_user_id] = current_user.id
compromised_password_path
elsif current_user.mfa_recommended_not_yet_enabled?
new_totp_path
elsif current_user.mfa_recommended_weak_level_enabled?
edit_settings_path
Expand All @@ -160,6 +166,38 @@ def url_after_create
end
end

def check_password_compromised
return unless @user

checker = PasswordBreachChecker.new(params.dig(:session, :password))
return unless checker.breached?

StatsD.increment "login.password_compromised"

if @user.mfa_enabled?
handle_compromised_password_with_mfa
else
handle_compromised_password_without_mfa
end
end

def handle_compromised_password_with_mfa
@user.record_event!(Events::UserEvent::PASSWORD_COMPROMISED,
request:, mfa_enabled: true, action_taken: "password_reset_redirect")
session[:password_compromised] = true
end

def handle_compromised_password_without_mfa
@user.record_event!(Events::UserEvent::PASSWORD_COMPROMISED,
request:, mfa_enabled: false, action_taken: "email_reset_required")

reset_session

session[:compromised_password_user_id] = @user.id

redirect_to compromised_password_path
end

def ensure_not_blocked
user = User.find_by_blocked(who)
return unless user&.blocked_email
Expand Down
25 changes: 25 additions & 0 deletions app/helpers/users_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,29 @@ def twitter_url(user)
def show_policies_acknowledge_banner?(user)
user.present? && !user.policies_acknowledged?
end

def obfuscate_email(email)
return email if email.blank?

local, domain = email.split("@", 2)
return email unless domain

domain_name, tld = domain.split(".", 2)
return email unless tld

obfuscated_local = obfuscate_part(local, 1)
obfuscated_domain = obfuscate_part(domain_name, 1)

"#{obfuscated_local}@#{obfuscated_domain}.#{tld}"
end

private

def obfuscate_part(str, visible_chars)
return str if str.length <= visible_chars + 1

visible = str[0, visible_chars]
hidden_length = str.length - visible_chars
"#{visible}#{'*' * hidden_length}"
end
end
5 changes: 5 additions & 0 deletions app/models/events/user_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,10 @@ class Events::UserEvent < ApplicationRecord

PASSWORD_CHANGED = define_event "user:password:changed"

PASSWORD_COMPROMISED = define_event "user:password:compromised" do
attribute :mfa_enabled, :boolean
attribute :action_taken, :string
end

POLICIES_ACKNOWLEDGED = define_event "user:policies:acknowledged"
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

class Events::UserEvent::Password::CompromisedComponent < Events::TableDetailsComponent
def view_template
plain t(".mfa_status", status: additional.mfa_enabled ? t(".mfa_enabled") : t(".mfa_disabled"))
br
plain t(".action_taken", action: additional.action_taken.humanize)
end
end
63 changes: 63 additions & 0 deletions app/views/compromised_passwords/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<% @title = t('.title') %>
<% add_breadcrumb t("sign_in"), sign_in_path %>
<% add_breadcrumb t('.title') %>

<div class="flex items-center justify-center">
<div class="max-w-2xl w-full bg-white dark:bg-black border border-neutral-200 dark:border-neutral-800 rounded-xl shadow-md p-10">
<%= render AlertComponent.new(style: :warning) do %>
<span class="font-semibold"><%= t('.heading') %></span>
<% end %>

<p class="text-neutral-700 dark:text-neutral-300 mb-6">
<%= t('.subheading') %>
</p>

<p class="text-neutral-700 dark:text-neutral-300 mb-8">
<%= t('.explanation_html') %>
</p>

<div class="bg-neutral-100 dark:bg-neutral-900 rounded-lg p-5 mb-8">
<p class="text-neutral-700 dark:text-neutral-300 text-sm mb-2">
<%= t('.email_sent') %>
</p>
<p class="text-orange-600 dark:text-orange-400 font-semibold break-all">
<%= obfuscate_email(@user.email) %>
</p>
</div>

<p class="text-neutral-700 dark:text-neutral-300 font-medium mb-4">
<%= t('.next_steps') %>
</p>

<ol class="list-decimal list-inside space-y-3 text-neutral-600 dark:text-neutral-400 mb-8">
<li><%= t('.step_1') %></li>
<li><%= t('.step_2') %></li>
<% unless signed_in? %>
<li><%= t('.step_3') %></li>
<li><%= t('.step_4_html', link: link_to(t('.step_4_link_text'), new_totp_path, class: "text-orange-500 dark:text-orange-400 hover:underline")) %></li>
<% end %>
</ol>

<div class="border-t border-neutral-200 dark:border-neutral-700 pt-8 mb-8">
<p class="text-neutral-700 dark:text-neutral-300 font-medium mb-4">
<%= t('.learn_more') %>
</p>
<ul class="space-y-3 text-sm text-neutral-600 dark:text-neutral-400">
<li><%= t('.learn_more_hibp_html', link: link_to(t('.learn_more_hibp_link_text'), "https://haveibeenpwned.com", target: "_blank", rel: "noopener", class: "text-orange-500 dark:text-orange-400 hover:underline")) %></li>
<li><%= t('.learn_more_mfa_html', link: link_to(t('.learn_more_mfa_link_text'), "https://guides.rubygems.org/setting-up-multifactor-authentication/", target: "_blank", rel: "noopener", class: "text-orange-500 dark:text-orange-400 hover:underline")) %></li>
</ul>
</div>

<div class="flex justify-center">
<% if signed_in? %>
<%= render ButtonComponent.new(t('.continue_to_dashboard'), dashboard_path, type: :link, color: :primary) %>
<% else %>
<%= render ButtonComponent.new(t('.back_to_sign_in'), sign_in_path, type: :link, color: :secondary) %>
<% end %>
</div>

<p class="text-center text-sm text-neutral-500 dark:text-neutral-500 mt-8">
<%= t('.need_help_html', email: mail_to("[email protected]", "[email protected]", class: "text-orange-500 dark:text-orange-400 hover:underline")) %>
</p>
</div>
</div>
26 changes: 16 additions & 10 deletions app/views/multifactor_auths/_webauthn_prompt.html.erb
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
<div class="mfa__option">
<h2 class="page__subheading--block"> <%= t("multifactor_auths.prompt.security_device") %></h2>
<div class="t-body">
<p><%= t("multifactor_auths.prompt.webauthn_credential_note") %></p>
</div>
<%= form_tag @webauthn_verification_url, method: :post, class: "js-webauthn-session--form", data: { options: @webauthn_options.to_json } do %>
<div class="form_bottom">
<p hidden class="l-text-red-600 js-webauthn-session--error"></p>
<div>
<h2 class="text-lg font-medium text-neutral-800 dark:text-neutral-200 mb-2">
<%= t("multifactor_auths.prompt.security_device") %>
</h2>
<p class="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
<%= t("multifactor_auths.prompt.webauthn_credential_note") %>
</p>

<%= submit_tag t("multifactor_auths.prompt.sign_in_with_webauthn_credential"), class: 'js-webauthn-session--submit form__submit form__submit--no-hover' %>
</div>
<%= form_tag @webauthn_verification_url, method: :post, class: "js-webauthn-session--form", data: { turbo: false, options: @webauthn_options.to_json } do %>
<p hidden class="text-red-600 dark:text-red-400 text-sm mb-4 js-webauthn-session--error"></p>

<%= render ButtonComponent.new(
t("multifactor_auths.prompt.sign_in_with_webauthn_credential"),
type: :submit,
color: :primary,
additional_classes: "js-webauthn-session--submit"
) %>
<% end %>
</div>
96 changes: 63 additions & 33 deletions app/views/multifactor_auths/prompt.html.erb
Original file line number Diff line number Diff line change
@@ -1,39 +1,69 @@
<% @title = t("multifactor_authentication") %>
<% add_breadcrumb t("sign_in"), sign_in_path %>
<% add_breadcrumb t("multifactor_authentication") %>
<% content_for :head do %>
<meta name="turbo-cache-control" content="no-cache">
<% end %>

<div class="mfa__container">
<% if @user.webauthn_enabled?%>
<%= render "multifactor_auths/webauthn_prompt" %>
<% end %>
<div class="flex items-center justify-center">
<div class="max-w-lg w-full bg-white dark:bg-black border border-neutral-200 dark:border-neutral-800 rounded-xl shadow-md p-10">
<h1 class="text-xl font-semibold text-neutral-900 dark:text-white mb-6">
<%= t("multifactor_authentication") %>
</h1>

<% if @user.totp_enabled? || @user.webauthn_only_with_recovery? %>
<div class="mfa__option">
<% if @user.totp_enabled? %>
<h2 class="page__subheading--block"> <%= t(".otp_code") %></h2>
<% elsif @user.webauthn_only_with_recovery? %>
<h2 class="page__subheading--block"> <%= t(".recovery_code") %></h2>
<% end %>
<div class="t-body">
<p><%= t(".recovery_code_html") %></p>
</div>
<%= form_tag @otp_verification_url, method: :post do %>
<div class="text_field">
<% if @user.totp_enabled? %>
<%= label_tag :otp, t(".otp_or_recovery"), class: 'form__label' %>
<%= text_field_tag :otp, '', class: 'form__input', autofocus: true, autocomplete: "one-time-code" %>
<% elsif @user.webauthn_only_with_recovery? %>
<%= text_field_tag :otp,
'',
class: 'form__input',
autofocus: true,
autocomplete: :off,
aria: { label: t(".recovery_code") }
%>
<% end %>
</div>
<div class="form_bottom">
<%= submit_tag t("authenticate"), data: { disable_with: t("form_disable_with")}, class: "form__submit" %>
<% if @user.webauthn_enabled? %>
<%= render "multifactor_auths/webauthn_prompt" %>
<% end %>

<% if @user.totp_enabled? || @user.webauthn_only_with_recovery? %>
<% if @user.webauthn_enabled? %>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-200 dark:border-neutral-700"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-white dark:bg-black px-4 text-neutral-500 dark:text-neutral-400"><%= t("or") %></span>
</div>
</div>
<% end %>
</div>
<% end %>

<div>
<% if @user.totp_enabled? %>
<h2 class="text-lg font-medium text-neutral-800 dark:text-neutral-200 mb-2">
<%= t(".otp_code") %>
</h2>
<% elsif @user.webauthn_only_with_recovery? %>
<h2 class="text-lg font-medium text-neutral-800 dark:text-neutral-200 mb-2">
<%= t(".recovery_code") %>
</h2>
<% end %>

<p class="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
<%= t(".recovery_code_html") %>
</p>

<%= form_tag @otp_verification_url, method: :post, data: { turbo: false } do %>
<div class="mb-4">
<% if @user.totp_enabled? %>
<%= label_tag :otp, t(".otp_or_recovery"), class: "block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2" %>
<%= text_field_tag :otp, "",
class: "w-full px-4 py-3 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:ring-2 focus:ring-orange-500 focus:border-orange-500 outline-none transition",
autofocus: true,
autocomplete: "one-time-code" %>
<% elsif @user.webauthn_only_with_recovery? %>
<%= text_field_tag :otp, "",
class: "w-full px-4 py-3 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:ring-2 focus:ring-orange-500 focus:border-orange-500 outline-none transition",
autofocus: true,
autocomplete: :off,
aria: { label: t(".recovery_code") } %>
<% end %>
</div>

<div>
<%= render ButtonComponent.new(t("authenticate"), type: :submit, color: :primary) %>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
Loading
Loading