Skip to content

Commit

Permalink
Add tests to the magic login submodule (#95)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ebihara99999 authored and Ch4s3 committed Nov 29, 2017
1 parent 172659f commit 5f05584
Show file tree
Hide file tree
Showing 10 changed files with 402 additions and 0 deletions.
50 changes: 50 additions & 0 deletions lib/generators/sorcery/templates/initializer.rb
Expand Up @@ -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`
Expand Down
9 changes: 9 additions & 0 deletions 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
1 change: 1 addition & 0 deletions lib/sorcery.rb
Expand Up @@ -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

Expand Down
134 changes: 134 additions & 0 deletions 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
15 changes: 15 additions & 0 deletions 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
7 changes: 7 additions & 0 deletions spec/rails_app/app/mailers/sorcery_mailer.rb
Expand Up @@ -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
13 changes: 13 additions & 0 deletions spec/rails_app/app/views/sorcery_mailer/magic_login_email.html.erb
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
</head>
<body>
<h1>Hello, <%= @user.username %></h1>
<p>
To login without a password, just follow this link: <%= @url %>.
</p>
<p>Have a great day!</p>
</body>
</html>
@@ -0,0 +1,6 @@
Hello, <%= @user.username %>
===============================================

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

Have a great day!
@@ -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

0 comments on commit 5f05584

Please sign in to comment.