From 5f05584edc81cd4773a7c64251f755cc821ab981 Mon Sep 17 00:00:00 2001 From: ebi Date: Wed, 29 Nov 2017 13:17:42 +0900 Subject: [PATCH] Add tests to the magic login submodule (#95) * Add magic login feature * Use `.nil?` instead of `== nil` * Prepare for setting up database - create a migration file for spec - create a spec to be run and a shared_expmple file * Add configration tests change the default of `@magic_login_mailer_disabled` into true because the default breaks the tests * Change the configuration key, magic_login_mailer into magic_login_mailer_class * Add specs of `.generate_magic_login_token` * Add specs of `.clear_magic_login_token` * Add faliure case specs of `.magic_login_email` * Add success case specs of `.magic_login_email` * Refactoring: split the success case of the `.generate_magic_login_token` spec into two * Fix posix compliance offence: No newline at end of file --- .../sorcery/templates/initializer.rb | 50 ++++++ .../templates/migration/magic_login.rb | 9 ++ lib/sorcery.rb | 1 + lib/sorcery/model/submodules/magic_login.rb | 134 ++++++++++++++++ spec/active_record/user_magic_login_spec.rb | 15 ++ spec/rails_app/app/mailers/sorcery_mailer.rb | 7 + .../sorcery_mailer/magic_login_email.html.erb | 13 ++ .../sorcery_mailer/magic_login_email.text.erb | 6 + ...20170924151831_add_magic_login_to_users.rb | 17 ++ .../user_magic_login_shared_examples.rb | 150 ++++++++++++++++++ 10 files changed, 402 insertions(+) create mode 100644 lib/generators/sorcery/templates/migration/magic_login.rb create mode 100644 lib/sorcery/model/submodules/magic_login.rb create mode 100644 spec/active_record/user_magic_login_spec.rb create mode 100644 spec/rails_app/app/views/sorcery_mailer/magic_login_email.html.erb create mode 100644 spec/rails_app/app/views/sorcery_mailer/magic_login_email.text.erb create mode 100644 spec/rails_app/db/migrate/magic_login/20170924151831_add_magic_login_to_users.rb create mode 100644 spec/shared_examples/user_magic_login_shared_examples.rb diff --git a/lib/generators/sorcery/templates/initializer.rb b/lib/generators/sorcery/templates/initializer.rb index bc835478..36df69de 100644 --- a/lib/generators/sorcery/templates/initializer.rb +++ b/lib/generators/sorcery/templates/initializer.rb @@ -366,6 +366,56 @@ # # user.reset_password_time_between_emails = + # -- magic_login -- + # magic login code attribute name. + # Default: `:magic_login_token` + # + # user.magic_login_token_attribute_name = + + + # expires at attribute name. + # Default: `:magic_login_token_expires_at` + # + # user.magic_login_token_expires_at_attribute_name = + + + # when was email sent, used for hammering protection. + # Default: `:magic_login_email_sent_at` + # + # user.magic_login_email_sent_at_attribute_name = + + + # mailer class. Needed. + # Default: `nil` + # + # user.magic_login_mailer_class = + + + # magic login email method on your mailer class. + # Default: `:magic_login_email` + # + # user.magic_login_email_method_name = + + + # when true sorcery will not automatically + # email magic login details and allow you to + # manually handle how and when email is sent + # Default: `true` + # + # user.magic_login_mailer_disabled = + + + # how many seconds before the request expires. nil for never expires. + # Default: `nil` + # + # user.magic_login_expiration_period = + + + # hammering protection, how long in seconds to wait before allowing another email to be sent. + # Default: `5 * 60` + # + # user.magic_login_time_between_emails = + # -- brute_force_protection -- # Failed logins attribute name. # Default: `:failed_logins_count` diff --git a/lib/generators/sorcery/templates/migration/magic_login.rb b/lib/generators/sorcery/templates/migration/magic_login.rb new file mode 100644 index 00000000..a1a4082f --- /dev/null +++ b/lib/generators/sorcery/templates/migration/magic_login.rb @@ -0,0 +1,9 @@ +class SorceryMagicLogin < ActiveRecord::Migration + def change + add_column :<%= model_class_name.tableize %>, :magic_login_token, :string, :default => nil + add_column :<%= model_class_name.tableize %>, :magic_login_token_expires_at, :datetime, :default => nil + add_column :<%= model_class_name.tableize %>, :magic_login_email_sent_at, :datetime, :default => nil + + add_index :<%= model_class_name.tableize %>, :magic_login_token + end +end diff --git a/lib/sorcery.rb b/lib/sorcery.rb index e0df9312..d70266cf 100644 --- a/lib/sorcery.rb +++ b/lib/sorcery.rb @@ -18,6 +18,7 @@ module Submodules require 'sorcery/model/submodules/activity_logging' require 'sorcery/model/submodules/brute_force_protection' require 'sorcery/model/submodules/external' + require 'sorcery/model/submodules/magic_login' end end diff --git a/lib/sorcery/model/submodules/magic_login.rb b/lib/sorcery/model/submodules/magic_login.rb new file mode 100644 index 00000000..b1e534cc --- /dev/null +++ b/lib/sorcery/model/submodules/magic_login.rb @@ -0,0 +1,134 @@ +module Sorcery + module Model + module Submodules + # This submodule adds the ability to login via email without password. + # When the user requests an email is sent to him with a url. + # The url includes a token, which is also saved with the user's record in the db. + # The token has configurable expiration. + # When the user clicks the url in the email, providing the token has not yet expired, + # he will be able to login. + # + # When using this submodule, supplying a mailer is mandatory. + module MagicLogin + def self.included(base) + base.sorcery_config.class_eval do + attr_accessor :magic_login_token_attribute_name, # magic login code attribute name. + :magic_login_token_expires_at_attribute_name, # expires at attribute name. + :magic_login_email_sent_at_attribute_name, # when was email sent, used for hammering + # protection. + + :magic_login_mailer_class, # mailer class. Needed. + + :magic_login_mailer_disabled, # when true sorcery will not automatically + # email magic login details and allow you to + # manually handle how and when email is sent + + :magic_login_email_method_name, # magic login email method on your + # mailer class. + + :magic_login_expiration_period, # how many seconds before the request + # expires. nil for never expires. + + :magic_login_time_between_emails # hammering protection, how long to wait + # before allowing another email to be sent. + + end + + base.sorcery_config.instance_eval do + @defaults.merge!(:@magic_login_token_attribute_name => :magic_login_token, + :@magic_login_token_expires_at_attribute_name => :magic_login_token_expires_at, + :@magic_login_email_sent_at_attribute_name => :magic_login_email_sent_at, + :@magic_login_mailer_class => nil, + :@magic_login_mailer_disabled => true, + :@magic_login_email_method_name => :magic_login_email, + :@magic_login_expiration_period => 15 * 60, + :@magic_login_time_between_emails => 5 * 60) + + reset! + end + + base.extend(ClassMethods) + + base.sorcery_config.after_config << :validate_mailer_defined + base.sorcery_config.after_config << :define_magic_login_fields + + base.send(:include, InstanceMethods) + + end + + module ClassMethods + # Find user by token, also checks for expiration. + # Returns the user if token found and is valid. + def load_from_magic_login_token(token) + token_attr_name = @sorcery_config.magic_login_token_attribute_name + token_expiration_date_attr = @sorcery_config.magic_login_token_expires_at_attribute_name + load_from_token(token, token_attr_name, token_expiration_date_attr) + end + + protected + + # This submodule requires the developer to define his own mailer class to be used by it + # when magic_login_mailer_disabled is false + def validate_mailer_defined + msg = "To use magic_login submodule, you must define a mailer (config.magic_login_mailer_class = YourMailerClass)." + raise ArgumentError, msg if @sorcery_config.magic_login_mailer_class.nil? and @sorcery_config.magic_login_mailer_disabled == false + end + + def define_magic_login_fields + sorcery_adapter.define_field sorcery_config.magic_login_token_attribute_name, String + sorcery_adapter.define_field sorcery_config.magic_login_token_expires_at_attribute_name, Time + sorcery_adapter.define_field sorcery_config.magic_login_email_sent_at_attribute_name, Time + end + + end + + module InstanceMethods + # generates a reset code with expiration + def generate_magic_login_token! + config = sorcery_config + attributes = {config.magic_login_token_attribute_name => TemporaryToken.generate_random_token, + config.magic_login_email_sent_at_attribute_name => Time.now.in_time_zone} + attributes[config.magic_login_token_expires_at_attribute_name] = Time.now.in_time_zone + config.magic_login_expiration_period if config.magic_login_expiration_period + + self.sorcery_adapter.update_attributes(attributes) + end + + # generates a magic login code with expiration and sends an email to the user. + def deliver_magic_login_instructions! + mail = false + config = sorcery_config + # hammering protection + return false if !config.magic_login_time_between_emails.nil? && + self.send(config.magic_login_email_sent_at_attribute_name) && + self.send(config.magic_login_email_sent_at_attribute_name) > config.magic_login_time_between_emails.seconds.ago + + self.class.sorcery_adapter.transaction do + generate_magic_login_token! + unless config.magic_login_mailer_disabled + send_magic_login_email! + mail = true + end + end + mail + end + + # Clears the token. + def clear_magic_login_token! + config = sorcery_config + self.sorcery_adapter.update_attributes({ + config.magic_login_token_attribute_name => nil, + config.magic_login_token_expires_at_attribute_name => nil + }) + end + + protected + + def send_magic_login_email! + generic_send_email(:magic_login_email_method_name, :magic_login_mailer_class) + end + end + + end + end + end +end diff --git a/spec/active_record/user_magic_login_spec.rb b/spec/active_record/user_magic_login_spec.rb new file mode 100644 index 00000000..ea2e86aa --- /dev/null +++ b/spec/active_record/user_magic_login_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' +require 'shared_examples/user_magic_login_shared_examples' + +describe User, 'with magic_login submodule', active_record: true do + before(:all) do + ActiveRecord::Migrator.migrate("#{Rails.root}/db/migrate/magic_login") + User.reset_column_information + end + + after(:all) do + ActiveRecord::Migrator.rollback("#{Rails.root}/db/migrate/magic_login") + end + + it_behaves_like 'magic_login_model' +end diff --git a/spec/rails_app/app/mailers/sorcery_mailer.rb b/spec/rails_app/app/mailers/sorcery_mailer.rb index 4a415d43..bf68d59e 100644 --- a/spec/rails_app/app/mailers/sorcery_mailer.rb +++ b/spec/rails_app/app/mailers/sorcery_mailer.rb @@ -28,4 +28,11 @@ def send_unlock_token_email(user) mail(to: user.email, subject: 'Your account has been locked due to many wrong logins') end + + def magic_login_email(user) + @user = user + @url = 'http://example.com/login' + mail(to: user.email, + subject: 'Magic Login') + end end diff --git a/spec/rails_app/app/views/sorcery_mailer/magic_login_email.html.erb b/spec/rails_app/app/views/sorcery_mailer/magic_login_email.html.erb new file mode 100644 index 00000000..cf8243f3 --- /dev/null +++ b/spec/rails_app/app/views/sorcery_mailer/magic_login_email.html.erb @@ -0,0 +1,13 @@ + + + + + + +

Hello, <%= @user.username %>

+

+ To login without a password, just follow this link: <%= @url %>. +

+

Have a great day!

+ + diff --git a/spec/rails_app/app/views/sorcery_mailer/magic_login_email.text.erb b/spec/rails_app/app/views/sorcery_mailer/magic_login_email.text.erb new file mode 100644 index 00000000..64be0dd2 --- /dev/null +++ b/spec/rails_app/app/views/sorcery_mailer/magic_login_email.text.erb @@ -0,0 +1,6 @@ +Hello, <%= @user.username %> +=============================================== + +To login without a password, just follow this link: <%= @url %>. + +Have a great day! diff --git a/spec/rails_app/db/migrate/magic_login/20170924151831_add_magic_login_to_users.rb b/spec/rails_app/db/migrate/magic_login/20170924151831_add_magic_login_to_users.rb new file mode 100644 index 00000000..33196a6c --- /dev/null +++ b/spec/rails_app/db/migrate/magic_login/20170924151831_add_magic_login_to_users.rb @@ -0,0 +1,17 @@ +class AddMagicLoginToUsers < ActiveRecord::CompatibleLegacyMigration.migration_class + def self.up + add_column :users, :magic_login_token, :string, default: nil + add_column :users, :magic_login_token_expires_at, :datetime, default: nil + add_column :users, :magic_login_email_sent_at, :datetime, default: nil + + add_index :users, :magic_login_token + end + + def self.down + remove_index :users, :magic_login_token + + remove_column :users, :magic_login_token + remove_column :users, :magic_login_token_expires_at + remove_column :users, :magic_login_email_sent_at + end +end diff --git a/spec/shared_examples/user_magic_login_shared_examples.rb b/spec/shared_examples/user_magic_login_shared_examples.rb new file mode 100644 index 00000000..758a2526 --- /dev/null +++ b/spec/shared_examples/user_magic_login_shared_examples.rb @@ -0,0 +1,150 @@ +shared_examples_for "magic_login_model" do + let(:user) {create_new_user} + before(:each) do + User.sorcery_adapter.delete_all + end + + context 'loaded plugin configuration' do + let(:config) {User.sorcery_config} + + before(:all) do + sorcery_reload!([:magic_login]) + end + + after(:each) do + User.sorcery_config.reset! + end + + describe "enables configuration options" do + it do + sorcery_model_property_set(:magic_login_token_attribute_name, :test_magic_login_token) + expect(config.magic_login_token_attribute_name).to eq :test_magic_login_token + end + + it do + sorcery_model_property_set(:magic_login_token_expires_at_attribute_name, :test_magic_login_token_expires_at) + expect(config.magic_login_token_expires_at_attribute_name).to eq :test_magic_login_token_expires_at + end + + it do + sorcery_model_property_set(:magic_login_email_sent_at_attribute_name, :test_magic_login_email_sent_at) + expect(config.magic_login_email_sent_at_attribute_name).to eq :test_magic_login_email_sent_at + end + + it do + TestMailerClass = Class.new # need a mailer class to test + sorcery_model_property_set(:magic_login_mailer_class, TestMailerClass) + expect(config.magic_login_mailer_class).to eq TestMailerClass + end + + it do + sorcery_model_property_set(:magic_login_mailer_disabled, false) + expect(config.magic_login_mailer_disabled).to eq false + end + + it do + sorcery_model_property_set(:magic_login_email_method_name, :test_magic_login_email) + expect(config.magic_login_email_method_name).to eq :test_magic_login_email + end + + it do + sorcery_model_property_set(:magic_login_expiration_period, 100000000) + expect(config.magic_login_expiration_period).to eq 100000000 + end + + it do + sorcery_model_property_set(:magic_login_time_between_emails, 100000000) + expect(config.magic_login_time_between_emails).to eq 100000000 + end + end + + describe "#generate_magic_login_token!" do + context "magic_login_token is nil" do + it "magic_login_token_expires_at and magic_login_email_sent_at aren't nil " do + user.generate_magic_login_token! + expect(user.magic_login_token_expires_at).not_to be_nil + expect(user.magic_login_email_sent_at).not_to be_nil + end + + it "magic_login_token is different from the one before" do + token_before = user.magic_login_token + user.generate_magic_login_token! + expect(user.magic_login_token).not_to eq token_before + end + end + + context "magic_login_token is not nil" do + it "changes `user.magic_login_token`" do + token_before = user.magic_login_token + user.generate_magic_login_token! + expect(user.magic_login_token).not_to eq token_before + end + end + end + + describe "#deliver_magic_login_instructions!" do + context "success" do + before do + sorcery_model_property_set(:magic_login_time_between_emails, 30*60) + sorcery_model_property_set(:magic_login_mailer_disabled, false) + Timecop.travel(10.days.ago) do + user.send(:"#{config.magic_login_email_sent_at_attribute_name}=", DateTime.now) + end + sorcery_model_property_set(:magic_login_mailer_class, ::SorceryMailer) + end + + it do + user.deliver_magic_login_instructions! + expect(ActionMailer::Base.deliveries.size).to eq 1 + end + + it do + expect(user.deliver_magic_login_instructions!).to eq true + end + end + + context "failure" do + context "magic_login_time_between_emails is nil" do + it "returns false" do + sorcery_model_property_set(:magic_login_time_between_emails, nil) + expect(user.deliver_magic_login_instructions!).to eq false + end + end + + context "magic_login_email_sent_at is nil" do + it "returns false" do + user.send(:"#{config.magic_login_email_sent_at_attribute_name}=", nil) + expect(user.deliver_magic_login_instructions!).to eq false + end + end + + context "now is before magic_login_email_sent_at plus the interval" do + it "returns false" do + user.send(:"#{config.magic_login_email_sent_at_attribute_name}=", DateTime.now) + sorcery_model_property_set(:magic_login_time_between_emails, 30*60) + expect(user.deliver_magic_login_instructions!).to eq false + end + end + + context "magic_login_mailer_disabled is true" do + it "returns false" do + sorcery_model_property_set(:magic_login_mailer_disabled, true) + expect(user.deliver_magic_login_instructions!).to eq false + end + end + end + end + + describe "#clear_magic_login_token!" do + it "makes magic_login_token_attribute_name and magic_login_token_expires_at_attribute_name nil" do + user.magic_login_token = "test_token" + user.magic_login_token_expires_at = Time.now + + user.clear_magic_login_token! + + expect(user.magic_login_token).to eq nil + expect(user.magic_login_token_expires_at).to eq nil + end + end + end +end