From 970283beb7f2bda32db5d56b47af485fc0aee464 Mon Sep 17 00:00:00 2001 From: Dave Kimura Date: Wed, 23 Oct 2024 22:47:18 -0400 Subject: [PATCH] SMS Authentication --- Gemfile | 4 +- Gemfile.lock | 137 ++++++++++-------- README.md | 53 +++++++ .../stylesheets/action_auth/application.css | 4 + .../sms_auths/requests_controller.rb | 31 ++++ .../sms_auths/sign_ins_controller.rb | 27 ++++ app/models/action_auth/user.rb | 10 +- .../action_auth/registrations/new.html.erb | 3 + app/views/action_auth/sessions/new.html.erb | 4 + .../sms_auths/requests/new.html.erb | 26 ++++ .../sms_auths/sign_ins/show.html.erb | 14 ++ config/routes.rb | 7 + ...0241020172209_add_phone_number_to_users.rb | 7 + .../action_auth/identity/emails/edit.html.erb | 3 + .../identity/password_resets/new.html.erb | 3 + .../action_auth/registrations/new.html.erb | 3 + examples/action_auth/sessions/new.html.erb | 3 + lib/action_auth/configuration.rb | 8 + lib/action_auth/version.rb | 2 +- lib/tasks/action_auth_tasks.rake | 7 + .../magics/requests_controller_test.rb | 2 +- .../sms_auths/requests_controller_test.rb | 42 ++++++ .../sms_auths/sign_ins_controller_test.rb | 61 ++++++++ test/dummy/app/models/sms_sender.rb | 16 ++ test/dummy/config/initializers/action_auth.rb | 7 + test/dummy/db/schema.rb | 5 +- test/test_helper.rb | 6 + 27 files changed, 429 insertions(+), 66 deletions(-) create mode 100644 app/controllers/action_auth/sms_auths/requests_controller.rb create mode 100644 app/controllers/action_auth/sms_auths/sign_ins_controller.rb create mode 100644 app/views/action_auth/sms_auths/requests/new.html.erb create mode 100644 app/views/action_auth/sms_auths/sign_ins/show.html.erb create mode 100644 db/migrate/20241020172209_add_phone_number_to_users.rb create mode 100644 test/controllers/action_auth/sms_auths/requests_controller_test.rb create mode 100644 test/controllers/action_auth/sms_auths/sign_ins_controller_test.rb create mode 100644 test/dummy/app/models/sms_sender.rb diff --git a/Gemfile b/Gemfile index 1c4d4ea..c064c02 100755 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } gemspec gem "puma" -gem "sqlite3", "~> 1.7" +gem "sqlite3" gem "sprockets-rails" group :development do @@ -22,3 +22,5 @@ gem "webauthn" # Add these gems for pwened password support gem "pwned" + +gem "twilio-ruby" diff --git a/Gemfile.lock b/Gemfile.lock index f21bbe0..4576db4 100755 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,36 +1,36 @@ PATH remote: . specs: - action_auth (1.6.0) + action_auth (1.7.0) bcrypt (~> 3.1.0) rails (>= 7.1) GEM remote: https://rubygems.org/ specs: - actioncable (7.2.1) - actionpack (= 7.2.1) - activesupport (= 7.2.1) + actioncable (7.2.1.2) + actionpack (= 7.2.1.2) + activesupport (= 7.2.1.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.1) - actionpack (= 7.2.1) - activejob (= 7.2.1) - activerecord (= 7.2.1) - activestorage (= 7.2.1) - activesupport (= 7.2.1) + actionmailbox (7.2.1.2) + actionpack (= 7.2.1.2) + activejob (= 7.2.1.2) + activerecord (= 7.2.1.2) + activestorage (= 7.2.1.2) + activesupport (= 7.2.1.2) mail (>= 2.8.0) - actionmailer (7.2.1) - actionpack (= 7.2.1) - actionview (= 7.2.1) - activejob (= 7.2.1) - activesupport (= 7.2.1) + actionmailer (7.2.1.2) + actionpack (= 7.2.1.2) + actionview (= 7.2.1.2) + activejob (= 7.2.1.2) + activesupport (= 7.2.1.2) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.1) - actionview (= 7.2.1) - activesupport (= 7.2.1) + actionpack (7.2.1.2) + actionview (= 7.2.1.2) + activesupport (= 7.2.1.2) nokogiri (>= 1.8.5) racc rack (>= 2.2.4, < 3.2) @@ -39,35 +39,35 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (7.2.1) - actionpack (= 7.2.1) - activerecord (= 7.2.1) - activestorage (= 7.2.1) - activesupport (= 7.2.1) + actiontext (7.2.1.2) + actionpack (= 7.2.1.2) + activerecord (= 7.2.1.2) + activestorage (= 7.2.1.2) + activesupport (= 7.2.1.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.1) - activesupport (= 7.2.1) + actionview (7.2.1.2) + activesupport (= 7.2.1.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.2.1) - activesupport (= 7.2.1) + activejob (7.2.1.2) + activesupport (= 7.2.1.2) globalid (>= 0.3.6) - activemodel (7.2.1) - activesupport (= 7.2.1) - activerecord (7.2.1) - activemodel (= 7.2.1) - activesupport (= 7.2.1) + activemodel (7.2.1.2) + activesupport (= 7.2.1.2) + activerecord (7.2.1.2) + activemodel (= 7.2.1.2) + activesupport (= 7.2.1.2) timeout (>= 0.4.0) - activestorage (7.2.1) - actionpack (= 7.2.1) - activejob (= 7.2.1) - activerecord (= 7.2.1) - activesupport (= 7.2.1) + activestorage (7.2.1.2) + actionpack (= 7.2.1.2) + activejob (= 7.2.1.2) + activerecord (= 7.2.1.2) + activesupport (= 7.2.1.2) marcel (~> 1.0) - activesupport (7.2.1) + activesupport (7.2.1.2) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) @@ -100,6 +100,12 @@ GEM docile (1.4.1) drb (2.2.1) erubi (1.13.0) + faraday (2.12.0) + faraday-net_http (>= 2.0, < 3.4) + json + logger + faraday-net_http (3.3.0) + net-http globalid (1.2.1) activesupport (>= 6.1) i18n (1.14.6) @@ -108,6 +114,7 @@ GEM irb (1.14.1) rdoc (>= 4.0.0) reline (>= 0.4.2) + json (2.7.2) jwt (2.9.3) base64 launchy (3.0.1) @@ -128,7 +135,9 @@ GEM mini_mime (1.1.5) minitest (5.25.1) minitest-stub_any_instance (1.0.3) - net-imap (0.4.16) + net-http (0.4.1) + uri + net-imap (0.5.0) date net-protocol net-pop (0.1.2) @@ -154,7 +163,7 @@ GEM nio4r (~> 2.0) pwned (2.4.1) racc (1.8.1) - rack (3.1.7) + rack (3.1.8) rack-session (2.0.0) rack (>= 3.0.0) rack-test (2.1.0) @@ -162,20 +171,20 @@ GEM rackup (2.1.0) rack (>= 3) webrick (~> 1.8) - rails (7.2.1) - actioncable (= 7.2.1) - actionmailbox (= 7.2.1) - actionmailer (= 7.2.1) - actionpack (= 7.2.1) - actiontext (= 7.2.1) - actionview (= 7.2.1) - activejob (= 7.2.1) - activemodel (= 7.2.1) - activerecord (= 7.2.1) - activestorage (= 7.2.1) - activesupport (= 7.2.1) + rails (7.2.1.2) + actioncable (= 7.2.1.2) + actionmailbox (= 7.2.1.2) + actionmailer (= 7.2.1.2) + actionpack (= 7.2.1.2) + actiontext (= 7.2.1.2) + actionview (= 7.2.1.2) + activejob (= 7.2.1.2) + activemodel (= 7.2.1.2) + activerecord (= 7.2.1.2) + activestorage (= 7.2.1.2) + activesupport (= 7.2.1.2) bundler (>= 1.15.0) - railties (= 7.2.1) + railties (= 7.2.1.2) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -183,9 +192,9 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - railties (7.2.1) - actionpack (= 7.2.1) - activesupport (= 7.2.1) + railties (7.2.1.2) + actionpack (= 7.2.1.2) + activesupport (= 7.2.1.2) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -212,9 +221,9 @@ GEM actionpack (>= 6.1) activesupport (>= 6.1) sprockets (>= 3.0.0) - sqlite3 (1.7.3-aarch64-linux) - sqlite3 (1.7.3-arm64-darwin) - sqlite3 (1.7.3-x86_64-linux) + sqlite3 (2.1.1-aarch64-linux-gnu) + sqlite3 (2.1.1-arm64-darwin) + sqlite3 (2.1.1-x86_64-linux-gnu) stringio (3.1.1) thor (1.3.2) timeout (0.4.1) @@ -222,8 +231,13 @@ GEM bindata (~> 2.4) openssl (> 2.0) openssl-signature_algorithm (~> 1.0) + twilio-ruby (7.3.4) + faraday (>= 0.9, < 3.0) + jwt (>= 1.5, < 3.0) + nokogiri (>= 1.6, < 2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + uri (0.13.1) useragent (0.16.10) webauthn (3.1.0) android_key_attestation (~> 0.3.0) @@ -238,7 +252,7 @@ GEM websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.6.18) + zeitwerk (2.7.1) PLATFORMS aarch64-linux @@ -254,7 +268,8 @@ DEPENDENCIES pwned simplecov sprockets-rails - sqlite3 (~> 1.7) + sqlite3 + twilio-ruby webauthn BUNDLED WITH diff --git a/README.md b/README.md index 1548e85..137602e 100755 --- a/README.md +++ b/README.md @@ -121,11 +121,18 @@ ActionAuth.configure do |config| config.magic_link_enabled = true config.passkey_only = true # Allows sign in with only a passkey config.pwned_enabled = true # defined?(Pwned) + config.sms_auth_enabled = false config.verify_email_on_sign_in = true config.webauthn_enabled = true # defined?(WebAuthn) config.webauthn_origin = "http://localhost:3000" # or "https://example.com" config.webauthn_rp_name = Rails.application.class.to_s.deconstantize end + +Rails.application.config.after_initialize do + ActionAuth.configure do |config| + config.sms_send_class = SmsSender + end +end ``` ## Features @@ -152,6 +159,8 @@ These are the planned features for ActionAuth. The ones that are checked off are ⏳ - OAuth with Google, Facebook, Github, Twitter, etc. +✅ - SMS Authentication + ✅ - Have I Been Pwned Integration ✅ - Account Deletion @@ -245,6 +254,50 @@ an email to the user with a link that will log them in. This is a great way to a without having to remember a password. This is especially useful for users who may not have a password manager or have a hard time remembering passwords. +### SMS Authentication + +SMS Authentication is disabled by default. The purpose of this is to allow users to authenticate +with a phone number. This is useful and specific to applications that may require a phone number +instead of an email address for authentication. The basic workflow for this is to register a phone +number, and then send a code to the phone number. The user will then enter the code to authenticate. + +No password or email is required for this. I do not recommend enabling this feature for most applications. + +You must set up your own SMS Provider. This is not included in the gem. You will need to configure the +`sms_send_class` to send the SMS code. This will expect a class method called `send_code` that will take in the parameters +`phone_number` and `code`. + +```ruby +require 'twilio-ruby' + +class SmsSender + def self.send_code(phone_number, code) + account_sid = ENV['TWILIO_ACCOUNT_SID'] + auth_token = ENV['TWILIO_AUTH_TOKEN'] + from_number = ENV['TWILIO_PHONE_NUMBER'] + + client = Twilio::REST::Client.new(account_sid, auth_token) + + client.messages.create( + from: from_number, + to: phone_number, + body: "Your verification code is #{code}" + ) + end +end +``` + +Since this file could live in the `app/models` or elsewhere, we will need to set its configuration after the Rails +application has been loaded. This can be done in an initializer. + +```ruby +Rails.application.config.after_initialize do + ActionAuth.configure do |config| + config.sms_send_class = SmsSender + end +end +``` + ## Account Deletion Account deletion is a feature that is enabled by default. When a user deletes their account, the account diff --git a/app/assets/stylesheets/action_auth/application.css b/app/assets/stylesheets/action_auth/application.css index d4b12ba..8701a75 100755 --- a/app/assets/stylesheets/action_auth/application.css +++ b/app/assets/stylesheets/action_auth/application.css @@ -54,6 +54,8 @@ body { input[type="text"], input[type="email"], +input[type="number"], +input[type="tel"], input[type="password"] { -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); @@ -186,6 +188,8 @@ input[type="password"] { input[type="text"], input[type="email"], + input[type="number"], + input[type="tel"], input[type="password"] { color: #d5c4a1; background-color: #282828; diff --git a/app/controllers/action_auth/sms_auths/requests_controller.rb b/app/controllers/action_auth/sms_auths/requests_controller.rb new file mode 100644 index 0000000..e92aa38 --- /dev/null +++ b/app/controllers/action_auth/sms_auths/requests_controller.rb @@ -0,0 +1,31 @@ +module ActionAuth + class SmsAuths::RequestsController < ApplicationController + def new + end + + def create + @user = User.find_or_initialize_by(phone_number: params[:phone_number]) + if @user.new_record? + password = SecureRandom.hex(32) + + @user.email = "#{SecureRandom.hex(32)}@smsauth" + @user.password = password + @user.password_confirmation = password + @user.verified = false + + @user.save! + end + + code = rand(100_000..999_999) + @user.update!(sms_code: code, sms_code_sent_at: Time.current) + cookies.signed[:sms_auth_phone_number] = { value: @user.phone_number, httponly: true } + + if defined?(ActionAuth.configuration.sms_send_class) && + ActionAuth.configuration.sms_send_class.respond_to?(:send_code) + ActionAuth.configuration.sms_send_class.send_code(@user.phone_number, code) + end + + redirect_to sms_auths_sign_ins_path, notice: "Check your phone for a SMS Code." + end + end +end diff --git a/app/controllers/action_auth/sms_auths/sign_ins_controller.rb b/app/controllers/action_auth/sms_auths/sign_ins_controller.rb new file mode 100644 index 0000000..2ca26c6 --- /dev/null +++ b/app/controllers/action_auth/sms_auths/sign_ins_controller.rb @@ -0,0 +1,27 @@ +module ActionAuth + class SmsAuths::SignInsController < ApplicationController + def show + @user = ActionAuth::User.find_by(phone_number: params[:phone_number]) + end + + def create + user = ActionAuth::User.find_by( + phone_number: cookies.signed[:sms_auth_phone_number], + sms_code: params[:sms_code], + sms_code_sent_at: 5.minutes.ago..Time.current + ) + if user + @session = user.sessions.create + cookies.signed.permanent[:session_token] = { value: @session.id, httponly: true } + user.update( + verified: true, + sms_code: nil, + sms_code_sent_at: nil + ) + redirect_to main_app.root_path, notice: "Signed In" + else + redirect_to sign_in_path, alert: "Authentication failed, please try again." + end + end + end +end diff --git a/app/models/action_auth/user.rb b/app/models/action_auth/user.rb index 132e4c2..fe3d934 100755 --- a/app/models/action_auth/user.rb +++ b/app/models/action_auth/user.rb @@ -26,7 +26,15 @@ class User < ApplicationRecord validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :password, allow_nil: true, length: { minimum: 12 } - + validates :phone_number, + allow_nil: true, + uniqueness: true, + format: { + with: /\A\+\d+\z/, + message: "must be a valid international phone number with digits only after the country code" + } + + normalizes :phone_number, with: -> phone_number { phone_number.gsub(/[^+\d]/, '') } normalizes :email, with: -> email { email.strip.downcase } before_validation if: :email_changed?, on: :update do diff --git a/app/views/action_auth/registrations/new.html.erb b/app/views/action_auth/registrations/new.html.erb index a7d84fe..ec3e550 100755 --- a/app/views/action_auth/registrations/new.html.erb +++ b/app/views/action_auth/registrations/new.html.erb @@ -39,6 +39,9 @@ <% if ActionAuth.configuration.magic_link_enabled? %> <%= link_to "Magic Link", new_magics_requests_path %> | <% end %> + <% if ActionAuth.configuration.sms_auth_enabled? %> + <%= link_to "SMS Auth", new_sms_auths_requests_path %> | + <% end %> <% if ActionAuth.configuration.passkey_only? %> <%= link_to "Passkey", new_sessions_passkey_path %> | <% end %> diff --git a/app/views/action_auth/sessions/new.html.erb b/app/views/action_auth/sessions/new.html.erb index 7fcece7..4cfbc9d 100755 --- a/app/views/action_auth/sessions/new.html.erb +++ b/app/views/action_auth/sessions/new.html.erb @@ -20,6 +20,10 @@ or <%= link_to "Magic Link", new_magics_requests_path %> <% end %> + <% if ActionAuth.configuration.sms_auth_enabled? %> + or + <%= link_to "SMS Auth", new_sms_auths_requests_path %> | + <% end %> <% if ActionAuth.configuration.passkey_only? %> or <%= link_to "Passkey", new_sessions_passkey_path %> diff --git a/app/views/action_auth/sms_auths/requests/new.html.erb b/app/views/action_auth/sms_auths/requests/new.html.erb new file mode 100644 index 0000000..324d9ef --- /dev/null +++ b/app/views/action_auth/sms_auths/requests/new.html.erb @@ -0,0 +1,26 @@ +

Request SMS Code

+ +<%= form_with(url: sms_auths_requests_path) do |form| %> +
+ <%= form.label :phone_number, style: "display: block" %> + <%= form.telephone_field :phone_number, required: true, autofocus: true, autocomplete: "mobile" %> +
+ +
+ <%= form.submit "Request SMS Code", class: "btn btn-primary" %> + or + <%= link_to "Sign In", sign_in_path %> + <% if ActionAuth.configuration.passkey_only? %> + or + <%= link_to "Passkey", new_sessions_passkey_path %> + <% end %> +
+<% end %> + +
+ <%= link_to "Sign Up", sign_up_path %> | + <%= link_to "Reset Password", new_identity_password_reset_path %> + <% if ActionAuth.configuration.verify_email_on_sign_in %> + | <%= link_to "Verify Email", identity_email_verification_path %> + <% end %> +
diff --git a/app/views/action_auth/sms_auths/sign_ins/show.html.erb b/app/views/action_auth/sms_auths/sign_ins/show.html.erb new file mode 100644 index 0000000..5280ec1 --- /dev/null +++ b/app/views/action_auth/sms_auths/sign_ins/show.html.erb @@ -0,0 +1,14 @@ +

Verify SMS Code

+ +<%= form_with(url: sms_auths_sign_ins_path) do |form| %> + <%= form.hidden_field :phone_number, value: cookies.signed[:sms_auth_phone_number] %> +
+ <%= form.label :sms_code, style: "display: block" %> + <%= form.number_field :sms_code, required: true, autofocus: true, autocomplete: "one-time-code" %> +
+ +
+ <%= form.submit "Sign In", class: "btn btn-primary" %> +
+<% end %> + diff --git a/config/routes.rb b/config/routes.rb index fccb690..4aaea9a 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,4 +35,11 @@ resource :requests, only: [:new, :create] end end + + if ActionAuth.configuration.sms_auth_enabled? + namespace :sms_auths do + resource :sign_ins, only: [:show, :create] + resource :requests, only: [:new, :create] + end + end end diff --git a/db/migrate/20241020172209_add_phone_number_to_users.rb b/db/migrate/20241020172209_add_phone_number_to_users.rb new file mode 100644 index 0000000..09b648d --- /dev/null +++ b/db/migrate/20241020172209_add_phone_number_to_users.rb @@ -0,0 +1,7 @@ +class AddPhoneNumberToUsers < ActiveRecord::Migration[7.2] + def change + add_column :users, :phone_number, :string + add_column :users, :sms_code, :string + add_column :users, :sms_code_sent_at, :datetime + end +end diff --git a/examples/action_auth/identity/emails/edit.html.erb b/examples/action_auth/identity/emails/edit.html.erb index 5edaef3..bb24145 100644 --- a/examples/action_auth/identity/emails/edit.html.erb +++ b/examples/action_auth/identity/emails/edit.html.erb @@ -41,6 +41,9 @@ <%= link_to "Sign In", sign_in_path, class: "font-medium text-blue-600 hover:underline" %> | <%= link_to "Sign Up", sign_up_path, class: "font-medium text-blue-600 hover:underline" %> | <%= link_to "Magic Link", new_magics_requests_path, class: "font-medium text-blue-600 hover:underline" %> | + <% if ActionAuth.configuration.sms_auth_enabled? %> + <%= link_to "SMS Auth", new_sms_auths_requests_path, class: "font-medium text-blue-600 hover:underline" %> | + <% end %> <%= link_to "Reset Password" , new_identity_password_reset_path, class: "font-medium text-blue-600 hover:underline" %> | <%= link_to "Verify Email" , identity_email_verification_path, class: "font-medium text-blue-600 hover:underline" %> diff --git a/examples/action_auth/identity/password_resets/new.html.erb b/examples/action_auth/identity/password_resets/new.html.erb index 3dc2c37..f12f019 100644 --- a/examples/action_auth/identity/password_resets/new.html.erb +++ b/examples/action_auth/identity/password_resets/new.html.erb @@ -23,6 +23,9 @@ <%= link_to "Sign In" , sign_in_path, class: "font-medium text-blue-600 hover:underline" %> | <%= link_to "Sign Up" , sign_up_path, class: "font-medium text-blue-600 hover:underline" %> | <%= link_to "Magic Link", new_magics_requests_path, class: "font-medium text-blue-600 hover:underline" %> | + <% if ActionAuth.configuration.sms_auth_enabled? %> + <%= link_to "SMS Auth", new_sms_auths_requests_path, class: "font-medium text-blue-600 hover:underline" %> | + <% end %> <%= link_to "Verify Email" , identity_email_verification_path, class: "font-medium text-blue-600 hover:underline" %> <% end %> diff --git a/examples/action_auth/registrations/new.html.erb b/examples/action_auth/registrations/new.html.erb index d0de431..9b875dd 100644 --- a/examples/action_auth/registrations/new.html.erb +++ b/examples/action_auth/registrations/new.html.erb @@ -37,6 +37,9 @@
<%= link_to "Magic Link", new_magics_requests_path, class: "font-medium text-blue-600 hover:underline" %> | + <% if ActionAuth.configuration.sms_auth_enabled? %> + <%= link_to "SMS Auth", new_sms_auths_requests_path, class: "font-medium text-blue-600 hover:underline" %> | + <% end %> <%= link_to "Reset Password" , new_identity_password_reset_path, class: "font-medium text-blue-600 hover:underline" %> | <%= link_to "Verify Email" , identity_email_verification_path, class: "font-medium text-blue-600 hover:underline" %>
diff --git a/examples/action_auth/sessions/new.html.erb b/examples/action_auth/sessions/new.html.erb index e11d6f6..8504dc8 100644 --- a/examples/action_auth/sessions/new.html.erb +++ b/examples/action_auth/sessions/new.html.erb @@ -30,6 +30,9 @@
<%= link_to "Magic Link", new_magics_requests_path, class: "font-medium text-blue-600 hover:underline" %> | + <% if ActionAuth.configuration.sms_auth_enabled? %> + <%= link_to "SMS Auth", new_sms_auths_requests_path, class: "font-medium text-blue-600 hover:underline" %> | + <% end %> <%= link_to "Reset Password" , new_identity_password_reset_path, class: "font-medium text-blue-600 hover:underline" %> | <%= link_to "Verify Email" , identity_email_verification_path, class: "font-medium text-blue-600 hover:underline" %>
diff --git a/lib/action_auth/configuration.rb b/lib/action_auth/configuration.rb index bb3787a..6761a68 100644 --- a/lib/action_auth/configuration.rb +++ b/lib/action_auth/configuration.rb @@ -6,6 +6,8 @@ class Configuration attr_accessor :magic_link_enabled attr_accessor :passkey_only attr_accessor :pwned_enabled + attr_accessor :sms_auth_enabled + attr_accessor :sms_send_class attr_accessor :verify_email_on_sign_in attr_accessor :webauthn_enabled attr_accessor :webauthn_origin @@ -18,6 +20,8 @@ def initialize @magic_link_enabled = true @passkey_only = true @pwned_enabled = defined?(Pwned) + @sms_auth_enabled = false + @sms_send_class = nil @verify_email_on_sign_in = true @webauthn_enabled = defined?(WebAuthn) @webauthn_origin = "http://localhost:3000" @@ -32,6 +36,10 @@ def magic_link_enabled? @magic_link_enabled == true end + def sms_auth_enabled? + @sms_auth_enabled == true + end + def passkey_only? webauthn_enabled? && @passkey_only == true end diff --git a/lib/action_auth/version.rb b/lib/action_auth/version.rb index 29b680b..7731a72 100755 --- a/lib/action_auth/version.rb +++ b/lib/action_auth/version.rb @@ -1,3 +1,3 @@ module ActionAuth - VERSION = "1.6.0" + VERSION = "1.7.0" end diff --git a/lib/tasks/action_auth_tasks.rake b/lib/tasks/action_auth_tasks.rake index c0edfca..49e6972 100755 --- a/lib/tasks/action_auth_tasks.rake +++ b/lib/tasks/action_auth_tasks.rake @@ -13,11 +13,18 @@ namespace :action_auth do # config.magic_link_enabled = true # config.passkey_only = true # Allows sign in with only a passkey # config.pwned_enabled = true # defined?(Pwned) + # config.sms_auth_enabled = false # config.verify_email_on_sign_in = true # config.webauthn_enabled = true # defined?(WebAuthn) # config.webauthn_origin = "http://localhost:3000" # or "https://example.com" # config.webauthn_rp_name = Rails.application.class.to_s.deconstantize # end + # + # Rails.application.config.after_initialize do + # ActionAuth.configure do |config| + # config.sms_send_class = SmsSender + # end + # end RUBY end puts "Created config/initializers/action_auth.rb" diff --git a/test/controllers/action_auth/magics/requests_controller_test.rb b/test/controllers/action_auth/magics/requests_controller_test.rb index 9e0a829..56aca8e 100644 --- a/test/controllers/action_auth/magics/requests_controller_test.rb +++ b/test/controllers/action_auth/magics/requests_controller_test.rb @@ -22,7 +22,7 @@ class Magics::RequestsControllerTest < ActionDispatch::IntegrationTest end test "should send magic link to existing user" do - existing_user = action_auth_users(:one) # assuming you have a fixture for this + existing_user = action_auth_users(:one) assert_no_difference('User.count') do post magics_requests_url, params: { email: existing_user.email } end diff --git a/test/controllers/action_auth/sms_auths/requests_controller_test.rb b/test/controllers/action_auth/sms_auths/requests_controller_test.rb new file mode 100644 index 0000000..238d198 --- /dev/null +++ b/test/controllers/action_auth/sms_auths/requests_controller_test.rb @@ -0,0 +1,42 @@ +require 'test_helper' + +class ActionAuth::SmsAuths::RequestsControllerTest < ActionDispatch::IntegrationTest + include ActionAuth::Engine.routes.url_helpers + + def setup + assert ActionAuth.configuration.sms_auth_enabled? + @existing_user = action_auth_users(:one) + @existing_user.update(phone_number: '+11234567890', verified: true) + @signed_cookies = ActionDispatch::Request.new(Rails.application.env_config.deep_dup).cookie_jar + end + + test "should get new" do + get new_sms_auths_requests_path + assert_response :success + end + + test "should create a new user and send SMS code" do + assert_difference('User.count', 1) do + post sms_auths_requests_path, params: { phone_number: '+19876543210' } + end + + new_user = User.find_by(phone_number: '+19876543210') + assert_not_nil new_user + assert_not_nil new_user.sms_code + assert_not_nil new_user.sms_code_sent_at + + assert_equal 'Check your phone for a SMS Code.', flash[:notice] + assert_redirected_to sms_auths_sign_ins_path + end + + test "should update existing user and send SMS code" do + post sms_auths_requests_path, params: { phone_number: @existing_user.phone_number } + + @existing_user.reload + assert_not_nil @existing_user.sms_code + assert_not_nil @existing_user.sms_code_sent_at + + assert_equal 'Check your phone for a SMS Code.', flash[:notice] + assert_redirected_to sms_auths_sign_ins_path + end +end diff --git a/test/controllers/action_auth/sms_auths/sign_ins_controller_test.rb b/test/controllers/action_auth/sms_auths/sign_ins_controller_test.rb new file mode 100644 index 0000000..df643d1 --- /dev/null +++ b/test/controllers/action_auth/sms_auths/sign_ins_controller_test.rb @@ -0,0 +1,61 @@ +require 'test_helper' + +class ActionAuth::SmsAuths::SignInsControllerTest < ActionDispatch::IntegrationTest + include ActionAuth::Engine.routes.url_helpers + + def setup + @user = action_auth_users(:one) + @user.update(phone_number: '1234567890', sms_code: '123456', sms_code_sent_at: 2.minutes.ago) + @signed_cookies = ActionDispatch::Request.new(Rails.application.env_config.deep_dup).cookie_jar + end + + test "should show user for valid phone number" do + get sms_auths_sign_ins_path, params: { phone_number: @user.phone_number } + assert_response :success + end + + test "should not find user for invalid phone number" do + get sms_auths_sign_ins_path, params: { phone_number: '9876543210' } + assert_response :success + end + + test "should sign in user with correct SMS code" do + @signed_cookies.signed[:sms_auth_phone_number] = @user.phone_number + assert_not_nil @user.sms_code + assert_not_nil @user.sms_code_sent_at + post sms_auths_sign_ins_path, params: { sms_code: @user.sms_code } + + @user.reload + assert_nil @user.sms_code + assert_nil @user.sms_code_sent_at + + end + + test "should fail to sign in with incorrect SMS code" do + @signed_cookies.signed[:sms_auth_phone_number] = @user.phone_number + + post sms_auths_sign_ins_path, params: { sms_code: 'wrong_code' } + + assert_equal 'Authentication failed, please try again.', flash[:alert] + assert_redirected_to sign_in_path + assert_nil @signed_cookies.signed[:session_token] + end + + test "should fail to sign in with expired SMS code" do + @user.update(sms_code_sent_at: 10.minutes.ago) + @signed_cookies.signed[:sms_auth_phone_number] = @user.phone_number + + post sms_auths_sign_ins_path, params: { sms_code: @user.sms_code } + + assert_equal 'Authentication failed, please try again.', flash[:alert] + assert_redirected_to sign_in_path + assert_nil @signed_cookies.signed[:session_token] + end + + test "should redirect to sign in path if phone number is not in cookies" do + post sms_auths_sign_ins_path, params: { sms_code: @user.sms_code } + + assert_equal 'Authentication failed, please try again.', flash[:alert] + assert_redirected_to sign_in_path + end +end diff --git a/test/dummy/app/models/sms_sender.rb b/test/dummy/app/models/sms_sender.rb new file mode 100644 index 0000000..6116648 --- /dev/null +++ b/test/dummy/app/models/sms_sender.rb @@ -0,0 +1,16 @@ +require 'twilio-ruby' + +class SmsSender + def self.send_code(phone_number, code) + client = Twilio::REST::Client.new( + ENV['TWILIO_ACCOUNT_SID'], + ENV['TWILIO_AUTH_TOKEN'] + ) + + client.messages.create( + from: ENV['TWILIO_PHONE_NUMBER'], + to: phone_number, + body: "Your verification code is #{code}" + ) + end +end diff --git a/test/dummy/config/initializers/action_auth.rb b/test/dummy/config/initializers/action_auth.rb index 1ea77a6..fa28aa9 100644 --- a/test/dummy/config/initializers/action_auth.rb +++ b/test/dummy/config/initializers/action_auth.rb @@ -4,8 +4,15 @@ config.magic_link_enabled = true config.passkey_only = true # Allows sign in with only a passkey config.pwned_enabled = true # defined?(Pwned) + config.sms_auth_enabled = true config.verify_email_on_sign_in = true config.webauthn_enabled = true # defined?(WebAuthn) config.webauthn_origin = "http://localhost:3000" # or "https://example.com" config.webauthn_rp_name = Rails.application.class.to_s.deconstantize end + +Rails.application.config.after_initialize do + ActionAuth.configure do |config| + config.sms_send_class = SmsSender + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index 5b18fc3..5b0e5d8 100755 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_08_18_032321) do +ActiveRecord::Schema[7.2].define(version: 2024_10_20_172209) do create_table "posts", force: :cascade do |t| t.integer "user_id", null: false t.string "title" @@ -35,6 +35,9 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "webauthn_id" + t.string "phone_number" + t.string "sms_code" + t.datetime "sms_code_sent_at" t.index ["email"], name: "index_users_on_email", unique: true end diff --git a/test/test_helper.rb b/test/test_helper.rb index 4063a64..699dca4 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -21,6 +21,12 @@ ActiveSupport::TestCase.fixtures :all end +class SmsSender + def self.send_code(phone_number, code) + true + end +end + class ActionDispatch::IntegrationTest include SignInAsHelper def create_user_with_credential(email: 'user@example.com',