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 @@ -1003,6 +1003,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
39 changes: 38 additions & 1 deletion app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class SessionsController < Clearance::SessionsController

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 +152,10 @@ def set_login_flash
end

def url_after_create
if current_user.mfa_recommended_not_yet_enabled?
if session.delete(:password_compromised)
flash[:alert] = t("sessions.create.password_compromised_warning")
new_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 +164,39 @@ 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: "warning_shown")
session[:password_compromised] = true
flash[:alert] = t(".password_compromised_warning")
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
57 changes: 57 additions & 0 deletions app/views/compromised_passwords/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<% @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>
<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>
</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">
<%= render ButtonComponent.new(t('.back_to_sign_in'), sign_in_path, type: :link, color: :secondary) %>
</div>

<p class="text-center text-sm text-neutral-500 dark:text-neutral-500 mt-8">
<%= t('.need_help_html', email: mail_to("support@rubygems.org", "support@rubygems.org", class: "text-orange-500 dark:text-orange-400 hover:underline")) %>
</p>
</div>
</div>
18 changes: 9 additions & 9 deletions config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ class Rack::Attack
# Key: "rack::attack:#{Time.now.to_i/:period}:logins/ip:#{req.ip}"

protected_ui_actions = [
{ controller: "sessions", action: "create" },
{ controller: "users", action: "create" },
{ controller: "passwords", action: "edit" },
{ controller: "sessions", action: "authenticate" },
{ controller: "passwords", action: "create" },
{ controller: "profiles", action: "update" },
{ controller: "profiles", action: "destroy" },
{ controller: "email_confirmations", action: "create" },
{ controller: "reverse_dependencies", action: "index" }
{ controller: "sessions", action: "create" },
{ controller: "users", action: "create" },
{ controller: "passwords", action: "edit" },
{ controller: "sessions", action: "authenticate" },
{ controller: "passwords", action: "create" },
{ controller: "profiles", action: "update" },
{ controller: "profiles", action: "destroy" },
{ controller: "email_confirmations", action: "create" },
{ controller: "reverse_dependencies", action: "index" }
]

otp_create_action = { controller: "sessions", action: "otp_create" }
Expand Down
27 changes: 27 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,27 @@ en:
notice: Please confirm your password to continue.
create:
account_blocked: Your account was blocked by rubygems team. Please email support@rubygems.org to recover your account.
password_compromised_warning: "Your password was found in a data breach. Reset it now to keep your account secure."
compromised_passwords:
show:
title: Password Reset Required
heading: Password found in data breach
subheading: Your RubyGems.org account remains secure
explanation_html: The password you entered has been found in a public data breach from <strong>another website</strong>. This doesn't mean your RubyGems.org account was compromised, but because passwords are often reused, we require a reset to keep your account secure.
email_sent: "We've sent a password reset link to:"
next_steps: "Here's what to do next:"
step_1: Check your email inbox (and spam folder) for the password reset link
step_2: Click the link and create a new, unique password you don't use elsewhere
step_3: Sign in with your new password
step_4_html: Consider %{link} for additional security
step_4_link_text: enabling multi-factor authentication
learn_more: Learn more
learn_more_hibp_html: Check if your email appears in other breaches at %{link}
learn_more_hibp_link_text: Have I Been Pwned
learn_more_mfa_html: Protect your account with %{link}
learn_more_mfa_link_text: multi-factor authentication
back_to_sign_in: Back to Sign In
need_help_html: "Need help? Contact %{email}"
stats:
index:
title: Stats
Expand Down Expand Up @@ -1035,3 +1056,9 @@ en:
api_key_gem_html: "Gem: %{gem}"
api_key_mfa: "MFA: %{mfa}"
not_required: "Not required"
password:
password_compromised: "Password Compromised"
mfa_status: "MFA Status: %{status}"
mfa_enabled: "Enabled"
mfa_disabled: "Disabled"
action_taken: "Action: %{action}"
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@
post 'webauthn_edit', to: 'passwords#webauthn_edit', as: :webauthn_edit
end

resource :compromised_password, only: %i[show]

resource :session, only: %i[create destroy] do
post 'otp_create', to: 'sessions#otp_create', as: :otp_create
post 'webauthn_create', to: 'sessions#webauthn_create', as: :webauthn_create
Expand Down
17 changes: 17 additions & 0 deletions lib/password_breach_checker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class PasswordBreachChecker
def initialize(password)
@password = Pwned::Password.new(password.to_s)
end

def breached?
@password.pwned?
rescue Pwned::TimeoutError, Pwned::Error => e
Rails.logger.warn "HIBP check failed: #{e.class}"
StatsD.increment "login.hibp_check.error"
false
end

def inspect
"#<PasswordBreachChecker:#{object_id} password=[FILTERED] breached=#{breached?}>"
end
end
77 changes: 77 additions & 0 deletions test/functional/compromised_passwords_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
require "test_helper"

class CompromisedPasswordsControllerTest < ActionController::TestCase
include ActionMailer::TestHelper

setup do
@user = create(:user)
end

context "on GET to show" do
context "with valid session" do
setup do
@controller.session[:compromised_password_user_id] = @user.id
end

should "display password reset info page" do
get :show

assert_response :success
assert_select "span.font-semibold", I18n.t("compromised_passwords.show.heading")
assert_select "a[href=?]", sign_in_path
end

should "enqueue password reset email on first visit" do
assert_enqueued_emails 1 do
get :show
end

assert_response :success
end

should "not re-send password reset email on page refresh" do
@controller.session[:compromised_password_email_sent] = true

assert_enqueued_emails 0 do
get :show
end

assert_response :success
end

should "update user confirmation_token for password reset" do
original_token = @user.confirmation_token

get :show

assert_not_equal original_token, @user.reload.confirmation_token
end

should "set email_sent flag in session after sending" do
refute @controller.session[:compromised_password_email_sent]

get :show

assert @controller.session[:compromised_password_email_sent]
end
end

context "without valid session" do
should "redirect to sign in when session is missing" do
get :show

assert_redirected_to sign_in_path
end

should "redirect to sign in when user no longer exists" do
deleted_user_id = @user.id
@user.destroy
@controller.session[:compromised_password_user_id] = deleted_user_id

get :show

assert_redirected_to sign_in_path
end
end
end
end
Loading
Loading