From bc4673a7243f2108b33e7c5acd1a8086184fe509 Mon Sep 17 00:00:00 2001 From: Dave Kimura Date: Mon, 12 Aug 2024 21:58:12 -0400 Subject: [PATCH] Have I Been Pwned --- Gemfile | 5 ++- Gemfile.lock | 8 +++-- README.md | 34 +++++++++++++------ .../identity/password_resets_controller.rb | 11 ++++++ .../action_auth/passwords_controller.rb | 11 ++++++ .../action_auth/registrations_controller.rb | 25 +++++++++++--- .../identity/password_resets/edit.html.erb | 6 ++-- lib/action_auth/configuration.rb | 9 +++-- .../registrations_controller_test.rb | 12 +++++-- test/mailers/action_auth/user_mailer_test.rb | 6 ++++ 10 files changed, 100 insertions(+), 27 deletions(-) diff --git a/Gemfile b/Gemfile index 957e3f9..1c4d4ea 100755 --- a/Gemfile +++ b/Gemfile @@ -18,4 +18,7 @@ group :test do end # Add these gems for WebAuthn support -gem "webauthn", "~> 3.1" +gem "webauthn" + +# Add these gems for pwened password support +gem "pwned" diff --git a/Gemfile.lock b/Gemfile.lock index de04b42..bab26c6 100755 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -90,9 +90,9 @@ GEM cbor (0.5.9.8) childprocess (5.1.0) logger (~> 1.5) - concurrent-ruby (1.3.3) + concurrent-ruby (1.3.4) connection_pool (2.4.1) - cose (1.3.0) + cose (1.3.1) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) crass (1.0.6) @@ -152,6 +152,7 @@ GEM public_suffix (6.0.1) puma (6.4.2) nio4r (~> 2.0) + pwned (2.4.1) racc (1.8.1) rack (3.1.7) rack-session (2.0.0) @@ -249,10 +250,11 @@ DEPENDENCIES letter_opener minitest-stub_any_instance puma + pwned simplecov sprockets-rails sqlite3 (~> 1.7) - webauthn (~> 3.1) + webauthn BUNDLED WITH 2.4.22 diff --git a/README.md b/README.md index 5126442..db0bbc1 100755 --- a/README.md +++ b/README.md @@ -15,12 +15,13 @@ user experience akin to that offered by the well-regarded Devise gem. - [Routes](#routes) - [Helper Methods](#helper-methods) - [Restricting and Changing Routes](#restricting-and-changing-routes) -5. [WebAuthn](#webauthn) -6. [Within Your Application](#within-your-application) -7. Customizing +5. [Have I Been Pwned](#have-i-been-pwned) +6. [WebAuthn](#webauthn) +7. [Within Your Application](#within-your-application) +8. Customizing - [Sign In Page](https://github.com/kobaltz/action_auth/wiki/Overriding-Sign-In-page-view) -7. [License](#license) -8. [Credits](#credits) +9. [License](#license) +10. [Credits](#credits) ## Breaking Changes @@ -130,6 +131,8 @@ These are the planned features for ActionAuth. The ones that are checked off are ⏳ - OAuth with Google, Facebook, Github, Twitter, etc. +✅ - Have I Been Pwned Integration + ✅ - Account Deletion ⏳ - Account Lockout @@ -206,13 +209,15 @@ versus a user that is not logged in. end root to: 'welcome#index' -## WebAuthn +## Have I Been Pwned -ActionAuth's approach for WebAuthn is simplicity. It is used as a multifactor authentication step, -so users will still need to register their email address and password. Once the user is registered, -they can add a Passkey to their account. The Passkey could be an iCloud Keychain, a hardware security -key like a Yubikey, or a mobile device. If enabled and configured, the user will be prompted to use -their Passkey after they log in. +[Have I Been Pwned](https://haveibeenpwned.com/) is a way that youre able to check if a password has been compromised in a data breach. This is a great way to ensure that your users are using secure passwords. + +Add the `pwned` gem to your Gemfile. That's all you'll have to do to enable this functionality. + +```ruby +bundle add pwned +``` ## Magic Links @@ -236,6 +241,13 @@ will want to style this to fit your application and have some kind of confirmati <%= button_to "Delete Account", action_auth.users_path, method: :delete %>

``` +## WebAuthn + +ActionAuth's approach for WebAuthn is simplicity. It is used as a multifactor authentication step, +so users will still need to register their email address and password. Once the user is registered, +they can add a Passkey to their account. The Passkey could be an iCloud Keychain, a hardware security +key like a Yubikey, or a mobile device. If enabled and configured, the user will be prompted to use +their Passkey after they log in. #### Configuration diff --git a/app/controllers/action_auth/identity/password_resets_controller.rb b/app/controllers/action_auth/identity/password_resets_controller.rb index e5aef91..a138ea0 100644 --- a/app/controllers/action_auth/identity/password_resets_controller.rb +++ b/app/controllers/action_auth/identity/password_resets_controller.rb @@ -2,6 +2,7 @@ module ActionAuth module Identity class PasswordResetsController < ApplicationController before_action :set_user, only: %i[ edit update ] + before_action :validate_pwned_password, only: :update def new end @@ -41,6 +42,16 @@ def user_params def send_password_reset_email UserMailer.with(user: @user).password_reset.deliver_later end + + def validate_pwned_password + return unless ActionAuth.configuration.pwned_enabled? + + pwned = Pwned::Password.new(params[:password]) + if pwned.pwned? + @user.errors.add(:password, "has been pwned #{pwned.pwned_count} times. Please choose a different password.") + render :edit, status: :unprocessable_entity + end + end end end end diff --git a/app/controllers/action_auth/passwords_controller.rb b/app/controllers/action_auth/passwords_controller.rb index cdbfb02..7258738 100755 --- a/app/controllers/action_auth/passwords_controller.rb +++ b/app/controllers/action_auth/passwords_controller.rb @@ -1,6 +1,7 @@ module ActionAuth class PasswordsController < ApplicationController before_action :set_user + before_action :validate_pwned_password, only: :update def edit end @@ -22,5 +23,15 @@ def set_user def user_params params.permit(:password, :password_confirmation, :password_challenge).with_defaults(password_challenge: "") end + + def validate_pwned_password + return unless ActionAuth.configuration.pwned_enabled? + + pwned = Pwned::Password.new(params[:password]) + if pwned.pwned? + @user.errors.add(:password, "has been pwned #{pwned.pwned_count} times. Please choose a different password.") + render :new, status: :unprocessable_entity + end + end end end diff --git a/app/controllers/action_auth/registrations_controller.rb b/app/controllers/action_auth/registrations_controller.rb index 17a0a07..40d64d1 100755 --- a/app/controllers/action_auth/registrations_controller.rb +++ b/app/controllers/action_auth/registrations_controller.rb @@ -1,5 +1,7 @@ module ActionAuth class RegistrationsController < ApplicationController + before_action :validate_pwned_password, only: :create + def new @user = User.new end @@ -23,12 +25,25 @@ def create end private - def user_params - params.permit(:email, :password, :password_confirmation) - end - def send_email_verification - UserMailer.with(user: @user).email_verification.deliver_later + def user_params + params.permit(:email, :password, :password_confirmation) + end + + def send_email_verification + UserMailer.with(user: @user).email_verification.deliver_later + end + + def validate_pwned_password + return unless ActionAuth.configuration.pwned_enabled? + + pwned = Pwned::Password.new(params[:password]) + + if pwned.pwned? + @user = User.new(email: params[:email]) + @user.errors.add(:password, "has been pwned #{pwned.pwned_count} times. Please choose a different password.") + render :new, status: :unprocessable_entity end + end end end diff --git a/app/views/action_auth/identity/password_resets/edit.html.erb b/app/views/action_auth/identity/password_resets/edit.html.erb index ae594f9..18d3430 100644 --- a/app/views/action_auth/identity/password_resets/edit.html.erb +++ b/app/views/action_auth/identity/password_resets/edit.html.erb @@ -15,18 +15,18 @@ <%= form.hidden_field :sid, value: params[:sid] %> -
+
<%= form.label :password, "New password", style: "display: block" %> <%= form.password_field :password, required: true, autofocus: true, autocomplete: "new-password" %>
12 characters minimum.
-
+
<%= form.label :password_confirmation, "Confirm new password", style: "display: block" %> <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password" %>
- <%= form.submit "Save changes" %> + <%= form.submit "Save changes", class: "btn btn-primary" %>
<% end %> diff --git a/lib/action_auth/configuration.rb b/lib/action_auth/configuration.rb index 72138a9..8df28aa 100644 --- a/lib/action_auth/configuration.rb +++ b/lib/action_auth/configuration.rb @@ -14,6 +14,7 @@ def initialize @allow_user_deletion = true @default_from_email = "from@example.com" @magic_link_enabled = true + @pwned_enabled = defined?(Pwned) @verify_email_on_sign_in = true @webauthn_enabled = defined?(WebAuthn) @webauthn_origin = "http://localhost:3000" @@ -21,16 +22,20 @@ def initialize end def allow_user_deletion? - @allow_user_deletion.respond_to?(:call) ? @allow_user_deletion.call : @allow_user_deletion + @allow_user_deletion == true end def magic_link_enabled? - @magic_link_enabled.respond_to?(:call) ? @magic_link_enabled.call : @magic_link_enabled + @magic_link_enabled == true end def webauthn_enabled? @webauthn_enabled.respond_to?(:call) ? @webauthn_enabled.call : @webauthn_enabled end + def pwned_enabled? + @pwned_enabled.respond_to?(:call) ? @pwned_enabled.call : @pwned_enabled + end + end end diff --git a/test/controllers/action_auth/registrations_controller_test.rb b/test/controllers/action_auth/registrations_controller_test.rb index 313e191..7e1db67 100644 --- a/test/controllers/action_auth/registrations_controller_test.rb +++ b/test/controllers/action_auth/registrations_controller_test.rb @@ -12,7 +12,7 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest test "should sign up" do assert_difference("ActionAuth::User.count") do email = "#{SecureRandom.hex}@#{SecureRandom.hex}.com" - post sign_up_path, params: { email: email, password: "123456789012", password_confirmation: "123456789012" } + post sign_up_path, params: { email: email, password: email, password_confirmation: email } end assert_response :redirect end @@ -20,7 +20,15 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest test "should not sign up" do assert_no_difference("ActionAuth::User.count") do email = "#{SecureRandom.hex}@#{SecureRandom.hex}.com" - post sign_up_path, params: { email: email, password: "1234567890AB", password_confirmation: "123456789012" } + post sign_up_path, params: { email: email, password: email, password_confirmation: "123456789012" } + end + assert_response :unprocessable_entity + end + + test "should not sign up with pwned password" do + assert_no_difference("ActionAuth::User.count") do + email = "#{SecureRandom.hex}@#{SecureRandom.hex}.com" + post sign_up_path, params: { email: email, password: "Password1234", password_confirmation: "Password1234" } end assert_response :unprocessable_entity end diff --git a/test/mailers/action_auth/user_mailer_test.rb b/test/mailers/action_auth/user_mailer_test.rb index bcca0f5..0f1ff15 100755 --- a/test/mailers/action_auth/user_mailer_test.rb +++ b/test/mailers/action_auth/user_mailer_test.rb @@ -17,5 +17,11 @@ class UserMailerTest < ActionMailer::TestCase assert_equal "Verify your email", mail.subject assert_equal [@user.email], mail.to end + + test "magic_link" do + mail = ActionAuth::UserMailer.with(user: @user).magic_link + assert_equal "Sign in to your account", mail.subject + assert_equal [@user.email], mail.to + end end end