From 0c2e0e0504b43cc3c37b62cb82e281279ca828a4 Mon Sep 17 00:00:00 2001 From: jaykava Date: Sat, 4 Jul 2026 19:40:10 +0530 Subject: [PATCH] Add :magic_link_authenticatable module for passwordless sign-in Introduces a new opt-in Devise module that sends single-use, expiring magic links by email. Includes rate limiting, generators, views, mailer, routes, and tests for Active Record and Mongoid. --- CHANGELOG.md | 5 + README.md | 46 ++- .../devise/magic_links_controller.rb | 57 ++++ app/mailers/devise/mailer.rb | 5 + app/views/devise/magic_links/new.html.erb | 16 + .../mailer/magic_link_instructions.html.erb | 8 + app/views/devise/shared/_links.html.erb | 4 + config/locales/en.yml | 9 + lib/devise.rb | 17 + lib/devise/models/authenticatable.rb | 3 +- .../models/magic_link_authenticatable.rb | 220 +++++++++++++ lib/devise/modules.rb | 1 + lib/devise/rails/routes.rb | 12 +- .../strategies/magic_link_authenticatable.rb | 46 +++ .../active_record/devise_generator.rb | 6 + .../active_record/templates/migration.rb | 1 + .../templates/migration_existing.rb | 1 + .../devise/controllers_generator.rb | 2 +- lib/generators/devise/orm_helpers.rb | 2 +- lib/generators/devise/views_generator.rb | 3 +- lib/generators/mongoid/devise_generator.rb | 6 + .../controllers/magic_links_controller.rb | 25 ++ lib/generators/templates/devise.rb | 23 ++ .../markerb/magic_link_instructions.markerb | 9 + .../simple_form_for/magic_links/new.html.erb | 18 + test/controllers/url_helpers_test.rb | 5 + .../magic_link_authenticatable_test.rb | 273 +++++++++++++++ test/mailers/magic_link_instructions_test.rb | 83 +++++ test/mapping_test.rb | 4 +- .../models/magic_link_authenticatable_test.rb | 311 ++++++++++++++++++ test/rails_app/app/mongoid/user.rb | 6 + .../migrate/20100401102949_create_tables.rb | 6 + test/rails_app/db/schema.rb | 4 + test/rails_app/lib/shared_user.rb | 2 +- test/routes_test.rb | 14 + 35 files changed, 1244 insertions(+), 9 deletions(-) create mode 100644 app/controllers/devise/magic_links_controller.rb create mode 100644 app/views/devise/magic_links/new.html.erb create mode 100644 app/views/devise/mailer/magic_link_instructions.html.erb create mode 100644 lib/devise/models/magic_link_authenticatable.rb create mode 100644 lib/devise/strategies/magic_link_authenticatable.rb create mode 100644 lib/generators/templates/controllers/magic_links_controller.rb create mode 100644 lib/generators/templates/markerb/magic_link_instructions.markerb create mode 100644 lib/generators/templates/simple_form_for/magic_links/new.html.erb create mode 100644 test/integration/magic_link_authenticatable_test.rb create mode 100644 test/mailers/magic_link_instructions_test.rb create mode 100644 test/models/magic_link_authenticatable_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ccb0882a4..3296ed0fd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +### unreleased + +* enhancements + * Add `:magic_link_authenticatable` module, allowing users to sign in through a single-use, expiring magic link sent by email, without typing a password. Add the module to your model along with the `magic_link_token`/`magic_link_sent_at` columns to opt in. Configurable through `magic_link_keys` and `magic_link_within`. Magic link requests are rate limited per account (10 requests per hour by default, tracked through the `magic_link_requests_count`/`magic_link_first_request_at` columns), configurable through `magic_link_request_limit` and `magic_link_request_period`. + ### 5.0.4 - 2026-05-08 * security fixes diff --git a/README.md b/README.md index 37e2d31601..9c42b2c8ca 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ Devise is a flexible authentication solution for Rails based on Warden. It: * Allows you to have multiple models signed in at the same time; * Is based on a modularity concept: use only what you really need. -It's composed of 10 modules: +It's composed of 11 modules: * [Database Authenticatable](https://www.rubydoc.info/gems/devise/Devise/Models/DatabaseAuthenticatable): hashes and stores a password in the database to validate the authenticity of a user while signing in. The authentication can be done both through POST requests or HTTP Basic Authentication. +* [Magic Link Authenticatable](https://www.rubydoc.info/gems/devise/Devise/Models/MagicLinkAuthenticatable): sends a single-use, expiring sign in link (magic link) by email, allowing users to sign in without typing a password. * [Omniauthable](https://www.rubydoc.info/gems/devise/Devise/Models/Omniauthable): adds OmniAuth (https://github.com/omniauth/omniauth) support. * [Confirmable](https://www.rubydoc.info/gems/devise/Devise/Models/Confirmable): sends emails with confirmation instructions and verifies whether an account is already confirmed during sign in. * [Recoverable](https://www.rubydoc.info/gems/devise/Devise/Models/Recoverable): resets the user password and sends reset instructions. @@ -46,6 +47,7 @@ It's composed of 10 modules: - [Controller tests](#controller-tests) - [Integration tests](#integration-tests) - [OmniAuth](#omniauth) + - [Magic link sign in](#magic-link-sign-in) - [Configuring multiple models](#configuring-multiple-models) - [Active Job Integration](#active-job-integration) - [Password reset tokens and Rails logs](#password-reset-tokens-and-rails-logs) @@ -670,6 +672,46 @@ You can read more about OmniAuth support in the wiki: * https://github.com/heartcombo/devise/wiki/OmniAuth:-Overview +### Magic link sign in + +Devise can send single-use, expiring sign in links (magic links) by email, allowing users to sign in without typing a password. To use it, add the `:magic_link_authenticatable` module to your model and add the required columns to your migration: + +```ruby +# Inside your User model +devise :database_authenticatable, :magic_link_authenticatable + +# In a migration +add_column :users, :magic_link_token, :string +add_column :users, :magic_link_sent_at, :datetime +add_column :users, :magic_link_requests_count, :integer, default: 0, null: false +add_column :users, :magic_link_first_request_at, :datetime +add_index :users, :magic_link_token, unique: true +``` + +Users can then request a magic link at `/users/magic_link/new`. The email contains a link to `/users/magic_link?magic_link_token=abcdef` which signs the user in directly. Each link can be used only once and expires after `config.magic_link_within` (20 minutes by default). Like the other token-based flows, only the token digest is stored in the database. + +The keys used to look up the account when requesting a magic link can be configured with `config.magic_link_keys` (defaults to `[:email]`). + +Magic link requests are rate limited per account so the email delivery cannot be spammed: at most `config.magic_link_request_limit` links (10 by default) can be requested within `config.magic_link_request_period` (1 hour by default) — once the period since the first request has passed, the counter resets. Requests over the limit get a validation error telling the user when they can try again. Rate limiting can be disabled by setting `config.magic_link_request_limit = nil`, in which case the `magic_link_requests_count`/`magic_link_first_request_at` columns are not needed. + +Like the other Devise options, the rate limit can also be configured per model directly in the `devise` call, taking precedence over the initializer value: + +```ruby +class User < ApplicationRecord + # 5 magic links every 30 minutes for this model only + devise :database_authenticatable, :magic_link_authenticatable, + magic_link_request_limit: 5, magic_link_request_period: 30.minutes +end + +class Admin < ApplicationRecord + # disable magic link rate limiting for this model only + devise :database_authenticatable, :magic_link_authenticatable, + magic_link_request_limit: nil +end +``` + +Magic links respect the other modules used by your model: unconfirmed (with `:confirmable`) or locked (with `:lockable`) accounts cannot sign in through a magic link. + ### Configuring multiple models Devise allows you to set up as many Devise models as you want. If you want to have an Admin model with just authentication and timeout features, in addition to the User model above, just run: @@ -715,7 +757,7 @@ end ### Password reset tokens and Rails logs -If you enable the [Recoverable](https://www.rubydoc.info/gems/devise/Devise/Models/Recoverable) module, note that a stolen password reset token could give an attacker access to your application. Devise takes effort to generate random, secure tokens, and stores only token digests in the database, never plaintext. However the default logging behavior in Rails can cause plaintext tokens to leak into log files: +If you enable the [Recoverable](https://www.rubydoc.info/gems/devise/Devise/Models/Recoverable) or [Magic Link Authenticatable](https://www.rubydoc.info/gems/devise/Devise/Models/MagicLinkAuthenticatable) modules, note that a stolen password reset token or magic link token could give an attacker access to your application. Devise takes effort to generate random, secure tokens, and stores only token digests in the database, never plaintext. However the default logging behavior in Rails can cause plaintext tokens to leak into log files: 1. Action Mailer logs the entire contents of all outgoing emails to the DEBUG level. Password reset tokens delivered to users in email will be leaked. 2. Active Job logs all arguments to every enqueued job at the INFO level. If you configure Devise to use `deliver_later` to send password reset emails, password reset tokens will be leaked. diff --git a/app/controllers/devise/magic_links_controller.rb b/app/controllers/devise/magic_links_controller.rb new file mode 100644 index 0000000000..a753e3f82f --- /dev/null +++ b/app/controllers/devise/magic_links_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class Devise::MagicLinksController < DeviseController + prepend_before_action :require_no_authentication + prepend_before_action :allow_params_authentication!, only: :show + # Authenticate through #show only if coming from a magic link email + append_before_action :assert_magic_link_token_passed, only: :show + + # GET /resource/magic_link/new + def new + self.resource = resource_class.new + end + + # POST /resource/magic_link + def create + self.resource = resource_class.send_magic_link_instructions(resource_params) + yield resource if block_given? + + if successfully_sent?(resource) + respond_with({}, location: after_sending_magic_link_instructions_path_for(resource_name)) + else + respond_with(resource) + end + end + + # GET /resource/magic_link?magic_link_token=abcdef + def show + self.resource = warden.authenticate!(auth_options) + set_flash_message!(:notice, :signed_in) + sign_in(resource_name, resource) + yield resource if block_given? + respond_with_navigational(resource) { redirect_to after_sign_in_path_for(resource) } + end + + protected + + # The path used after sending magic link instructions. + def after_sending_magic_link_instructions_path_for(resource_name) + new_session_path(resource_name) if is_navigational_format? + end + + # Check if a magic_link_token is provided in the request. + def assert_magic_link_token_passed + if params[:magic_link_token].blank? + set_flash_message(:alert, :no_token) + redirect_to new_session_path(resource_name) + end + end + + def auth_options + { scope: resource_name, recall: "#{controller_path}#new", locale: I18n.locale } + end + + def translation_scope + 'devise.magic_links' + end +end diff --git a/app/mailers/devise/mailer.rb b/app/mailers/devise/mailer.rb index e617edcd0b..917f82c637 100644 --- a/app/mailers/devise/mailer.rb +++ b/app/mailers/devise/mailer.rb @@ -14,6 +14,11 @@ def reset_password_instructions(record, token, opts = {}) devise_mail(record, :reset_password_instructions, opts) end + def magic_link_instructions(record, token, opts = {}) + @token = token + devise_mail(record, :magic_link_instructions, opts) + end + def unlock_instructions(record, token, opts = {}) @token = token devise_mail(record, :unlock_instructions, opts) diff --git a/app/views/devise/magic_links/new.html.erb b/app/views/devise/magic_links/new.html.erb new file mode 100644 index 0000000000..d339711478 --- /dev/null +++ b/app/views/devise/magic_links/new.html.erb @@ -0,0 +1,16 @@ +

Sign in with a magic link

+ +<%= form_for(resource, as: resource_name, url: magic_link_path(resource_name), html: { method: :post }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+

<%= f.label :email %>

+

<%= f.email_field :email, autofocus: true, autocomplete: "email" %>

+
+ +
+ <%= f.submit "Send me a magic link" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/mailer/magic_link_instructions.html.erb b/app/views/devise/mailer/magic_link_instructions.html.erb new file mode 100644 index 0000000000..b41757f127 --- /dev/null +++ b/app/views/devise/mailer/magic_link_instructions.html.erb @@ -0,0 +1,8 @@ +

Hello <%= @resource.email %>!

+ +

Someone has requested a link to sign in to your account. You can do this through the link below.

+ +

<%= link_to 'Sign in to my account', magic_link_url(@resource, magic_link_token: @token) %>

+ +

If you didn't request this, please ignore this email.

+

The link can be used only once and will expire in a few minutes.

diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb index 21cf422d51..040f95935f 100644 --- a/app/views/devise/shared/_links.html.erb +++ b/app/views/devise/shared/_links.html.erb @@ -10,6 +10,10 @@

<%= link_to "Forgot your password?", new_password_path(resource_name) %>

<% end %> +<%- if devise_mapping.magic_link_authenticatable? && controller_name != 'magic_links' %> +

<%= link_to "Sign in with a magic link", new_magic_link_path(resource_name) %>

+<% end %> + <%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>

<%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>

<% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 260e1c4ba6..e785fb3aef 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -12,13 +12,21 @@ en: invalid: "Invalid %{authentication_keys} or password." locked: "Your account is locked." last_attempt: "You have one more attempt before your account is locked." + magic_link_invalid: "Invalid or expired magic link. Please request a new one." not_found_in_database: "Invalid %{authentication_keys} or password." timeout: "Your session expired. Please sign in again to continue." unauthenticated: "You need to sign in or sign up before continuing." unconfirmed: "You have to confirm your email address before continuing." + magic_links: + no_token: "You can't access this page without coming from a magic link email. If you do come from a magic link email, please make sure you used the full URL provided." + send_instructions: "You will receive an email with a magic link to sign in to your account in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive an email with a magic link to sign in to your account in a few minutes." + signed_in: "Signed in successfully." mailer: confirmation_instructions: subject: "Confirmation instructions" + magic_link_instructions: + subject: "Magic link sign in instructions" reset_password_instructions: subject: "Reset password instructions" unlock_instructions: @@ -58,6 +66,7 @@ en: already_confirmed: "was already confirmed, please try signing in" confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" expired: "has expired, please request a new one" + magic_link_request_limit_exceeded: "Too many magic links were requested. You can request a new one in %{period}." not_found: "not found" not_locked: "was not locked" not_saved: diff --git a/lib/devise.rb b/lib/devise.rb index 8e0c85e77d..eea013d120 100644 --- a/lib/devise.rb +++ b/lib/devise.rb @@ -203,6 +203,23 @@ module Test mattr_accessor :sign_in_after_reset_password @@sign_in_after_reset_password = true + # Defines which key will be used when requesting a magic link for an account + mattr_accessor :magic_link_keys + @@magic_link_keys = [:email] + + # Time interval you can sign in with a magic link token + mattr_accessor :magic_link_within + @@magic_link_within = 20.minutes + + # How many magic links can be requested for an account within + # magic_link_request_period. nil disables rate limiting. + mattr_accessor :magic_link_request_limit + @@magic_link_request_limit = 10 + + # Time period used by magic_link_request_limit to limit magic link requests. + mattr_accessor :magic_link_request_period + @@magic_link_request_period = 1.hour + # The default scope which is used by warden. mattr_accessor :default_scope @@default_scope = nil diff --git a/lib/devise/models/authenticatable.rb b/lib/devise/models/authenticatable.rb index df964537ea..cf9c3460a8 100644 --- a/lib/devise/models/authenticatable.rb +++ b/lib/devise/models/authenticatable.rb @@ -58,7 +58,8 @@ module Authenticatable UNSAFE_ATTRIBUTES_FOR_SERIALIZATION = [:encrypted_password, :reset_password_token, :reset_password_sent_at, :remember_created_at, :sign_in_count, :current_sign_in_at, :last_sign_in_at, :current_sign_in_ip, :last_sign_in_ip, :password_salt, :confirmation_token, :confirmed_at, :confirmation_sent_at, - :remember_token, :unconfirmed_email, :failed_attempts, :unlock_token, :locked_at] + :remember_token, :unconfirmed_email, :failed_attempts, :unlock_token, :locked_at, + :magic_link_token, :magic_link_sent_at, :magic_link_requests_count, :magic_link_first_request_at] included do class_attribute :devise_modules, instance_writer: false diff --git a/lib/devise/models/magic_link_authenticatable.rb b/lib/devise/models/magic_link_authenticatable.rb new file mode 100644 index 0000000000..dad06766b9 --- /dev/null +++ b/lib/devise/models/magic_link_authenticatable.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require 'devise/strategies/magic_link_authenticatable' + +module Devise + module Models + # MagicLinkAuthenticatable takes care of sending a one-time login link + # (a.k.a. "magic link") by email and authenticating a user through it, + # without requiring the user to type a password. + # + # The token sent in the e-mail is stored hashed in the database (in the + # same fashion as the reset password token from Recoverable) and is + # consumed on the first successful sign in, so each link can be used + # only once. Tokens also expire after +magic_link_within+. + # + # ==Options + # + # MagicLinkAuthenticatable adds the following options to +devise+: + # + # * +magic_link_keys+: the keys you want to use when requesting a + # magic link for an account. By default [:email]. + # * +magic_link_within+: the time period within which the magic link + # must be used or the token expires. By default 20.minutes. + # * +magic_link_request_limit+: how many magic links can be requested + # for an account within +magic_link_request_period+, to prevent the + # email delivery from being spammed. Set to nil to disable rate + # limiting. By default 10. + # * +magic_link_request_period+: the time period used by + # +magic_link_request_limit+. Once the first request of the period is + # this old, the counter resets. By default 1.hour. + # + # All options can be set globally in the Devise initializer or per model + # in the +devise+ call, which takes precedence over the global value: + # + # # 5 magic links every 30 minutes for this model only + # devise :magic_link_authenticatable, magic_link_request_limit: 5, + # magic_link_request_period: 30.minutes + # + # # disable rate limiting for this model only (the + # # magic_link_requests_count and magic_link_first_request_at + # # columns are not needed in this case) + # devise :magic_link_authenticatable, magic_link_request_limit: nil + # + # == Examples + # + # # creates a new token and sends it as a magic link by email + # User.find(1).send_magic_link_instructions + # + # # only verifies if a magic link can still be used + # User.find(1).magic_link_period_valid? + # + module MagicLinkAuthenticatable + extend ActiveSupport::Concern + + def self.required_fields(klass) + fields = [:magic_link_token, :magic_link_sent_at] + fields += [:magic_link_requests_count, :magic_link_first_request_at] if klass.magic_link_request_limit + fields + end + + included do + before_update :clear_magic_link_token, if: :clear_magic_link_token? + end + + # Resets the magic link token and sends the magic link by email, + # as long as the request rate limit was not exceeded (see + # +magic_link_request_limit+ and +magic_link_request_period+). + # Returns the raw token sent in the e-mail, or false when the + # request was rate limited (with an error added to the record). + def send_magic_link_instructions + return false unless register_magic_link_request + + token = set_magic_link_token + send_magic_link_instructions_notification(token) + + token + end + + # Checks if the magic link token is still within the valid time window. + # magic_link_within is a model configuration, must always be an integer value. + # + # Example: + # + # # magic_link_within = 20.minutes and magic_link_sent_at = 10.minutes.ago + # magic_link_period_valid? # returns true + # + # # magic_link_within = 20.minutes and magic_link_sent_at = 20.minutes.ago + # magic_link_period_valid? # returns false + # + def magic_link_period_valid? + magic_link_sent_at && magic_link_sent_at.utc >= self.class.magic_link_within.ago.utc + end + + # Removes the magic link token so the link can no longer be used, and + # persists the change. Called after a successful sign in through a + # magic link, making every link single-use. + def consume_magic_link_token! + clear_magic_link_token + save(validate: false) + end + + # A callback initiated after successfully authenticating through a + # magic link. This can be used to insert your own logic that is only + # run after the user successfully signs in with a magic link. + # + # Example: + # + # def after_magic_link_authentication + # self.update_attribute(:invite_code, nil) + # end + # + def after_magic_link_authentication + end + + # Checks if the resource already made as many magic link requests as + # allowed within the current period. Returns false when rate limiting + # is disabled (+magic_link_request_limit+ set to nil). + def magic_link_request_limit_exceeded? + return false unless self.class.magic_link_request_limit + return false if magic_link_request_period_expired? + + magic_link_requests_count.to_i >= self.class.magic_link_request_limit + end + + protected + + # Registers a magic link request against the rate limit, resetting + # the counter when the period expired. When the limit was already + # reached, adds an error to the record and returns false so no email + # is sent. The updated counter is persisted along with the token by + # +set_magic_link_token+. + def register_magic_link_request + return true unless self.class.magic_link_request_limit + + if magic_link_request_period_expired? + self.magic_link_requests_count = 0 + self.magic_link_first_request_at = Time.now.utc + end + + if magic_link_requests_count.to_i >= self.class.magic_link_request_limit + errors.add(:base, :magic_link_request_limit_exceeded, + period: Devise::TimeInflector.time_ago_in_words(magic_link_request_period_resets_at)) + false + else + self.magic_link_requests_count = magic_link_requests_count.to_i + 1 + true + end + end + + # Checks if the current rate limiting period is over, meaning the + # requests counter can be reset. + def magic_link_request_period_expired? + magic_link_first_request_at.nil? || + magic_link_first_request_at.utc < self.class.magic_link_request_period.ago.utc + end + + # The time when the current rate limiting period is over and new + # magic links can be requested again. + def magic_link_request_period_resets_at + magic_link_first_request_at.utc + self.class.magic_link_request_period + end + + # Removes magic link token. + def clear_magic_link_token + self.magic_link_token = nil + self.magic_link_sent_at = nil + end + + # Generates a new random token for the magic link and stores the + # hashed version in the database, returning the raw token. + def set_magic_link_token + raw, enc = Devise.token_generator.generate(self.class, :magic_link_token) + + self.magic_link_token = enc + self.magic_link_sent_at = Time.now.utc + save(validate: false) + raw + end + + def send_magic_link_instructions_notification(token) + send_devise_notification(:magic_link_instructions, token, {}) + end + + # Invalidates outstanding magic links whenever the credentials used + # to request them (or the password) change. + def clear_magic_link_token? + return false unless magic_link_token.present? + + encrypted_password_changed = devise_respond_to_and_will_save_change_to_attribute?(:encrypted_password) + authentication_keys_changed = self.class.authentication_keys.any? do |attribute| + devise_respond_to_and_will_save_change_to_attribute?(attribute) + end + + authentication_keys_changed || encrypted_password_changed + end + + module ClassMethods + # Attempt to find a user by its magic link token. If a user is found + # return it, otherwise return nil. + def with_magic_link_token(token) + magic_link_token = Devise.token_generator.digest(self, :magic_link_token, token) + to_adapter.find_first(magic_link_token: magic_link_token) + end + + # Attempt to find a user by its magic link keys (email by default). + # If a record is found, send a magic link to it. If the user is not + # found, returns a new user with an email not found error. + # Attributes must contain the user's magic link keys. + def send_magic_link_instructions(attributes = {}) + magic_link_authenticatable = find_or_initialize_with_errors(magic_link_keys, attributes, :not_found) + magic_link_authenticatable.send_magic_link_instructions if magic_link_authenticatable.persisted? + magic_link_authenticatable + end + + Devise::Models.config(self, :magic_link_keys, :magic_link_within, + :magic_link_request_limit, :magic_link_request_period) + end + end + end +end diff --git a/lib/devise/modules.rb b/lib/devise/modules.rb index d8cde834c1..684717d709 100644 --- a/lib/devise/modules.rb +++ b/lib/devise/modules.rb @@ -7,6 +7,7 @@ d.with_options strategy: true do |s| routes = [nil, :new, :destroy] s.add_module :database_authenticatable, controller: :sessions, route: { session: routes } + s.add_module :magic_link_authenticatable, controller: :magic_links, route: { magic_link: [nil, :new] } s.add_module :rememberable, no_input: true end diff --git a/lib/devise/rails/routes.rb b/lib/devise/rails/routes.rb index f43e62fea7..ab039adf76 100644 --- a/lib/devise/rails/routes.rb +++ b/lib/devise/rails/routes.rb @@ -63,6 +63,11 @@ class Mapper # user_confirmation GET /users/confirmation(.:format) {controller:"devise/confirmations", action:"show"} # POST /users/confirmation(.:format) {controller:"devise/confirmations", action:"create"} # + # # Magic link routes for MagicLinkAuthenticatable, if User model has :magic_link_authenticatable configured + # new_user_magic_link GET /users/magic_link/new(.:format) {controller:"devise/magic_links", action:"new"} + # user_magic_link GET /users/magic_link(.:format) {controller:"devise/magic_links", action:"show"} + # POST /users/magic_link(.:format) {controller:"devise/magic_links", action:"create"} + # # ==== Routes integration # # +devise_for+ is meant to play nicely with other routes methods. For example, @@ -119,7 +124,7 @@ class Mapper # end # # * path_names: configure different path names to overwrite defaults :sign_in, :sign_out, :sign_up, - # :password, :confirmation, :unlock. + # :password, :confirmation, :unlock, :magic_link. # # devise_for :users, path_names: { # sign_in: 'login', sign_out: 'logout', @@ -386,6 +391,11 @@ def devise_password(mapping, controllers) #:nodoc: path: mapping.path_names[:password], controller: controllers[:passwords] end + def devise_magic_link(mapping, controllers) #:nodoc: + resource :magic_link, only: [:new, :create, :show], + path: mapping.path_names[:magic_link], controller: controllers[:magic_links] + end + def devise_confirmation(mapping, controllers) #:nodoc: resource :confirmation, only: [:new, :create, :show], path: mapping.path_names[:confirmation], controller: controllers[:confirmations] diff --git a/lib/devise/strategies/magic_link_authenticatable.rb b/lib/devise/strategies/magic_link_authenticatable.rb new file mode 100644 index 0000000000..669ad974b9 --- /dev/null +++ b/lib/devise/strategies/magic_link_authenticatable.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'devise/strategies/authenticatable' + +module Devise + module Strategies + # Strategy for signing in a user through a one-time magic link token + # sent by email. The token is only accepted while it's within the + # configured time window and is consumed on the first successful + # sign in, so it cannot be reused. + class MagicLinkAuthenticatable < Authenticatable + def valid? + valid_params_request? && magic_link_token.present? + end + + def authenticate! + resource = mapping.to.with_magic_link_token(magic_link_token) + + if resource && resource.magic_link_period_valid? + if validate(resource) + resource.consume_magic_link_token! + remember_me(resource) + resource.after_magic_link_authentication + success!(resource) + end + else + fail(:magic_link_invalid) + end + end + + # A magic link sign in comes from a link clicked in an email, so there + # is no password to clean up and CSRF data should still be reset. + def clean_up_csrf? + true + end + + private + + def magic_link_token + params[:magic_link_token] + end + end + end +end + +Warden::Strategies.add(:magic_link_authenticatable, Devise::Strategies::MagicLinkAuthenticatable) diff --git a/lib/generators/active_record/devise_generator.rb b/lib/generators/active_record/devise_generator.rb index 89b2f94ddb..3c382c7ca2 100644 --- a/lib/generators/active_record/devise_generator.rb +++ b/lib/generators/active_record/devise_generator.rb @@ -53,6 +53,12 @@ def migration_data ## Rememberable t.datetime :remember_created_at + ## Magic link authenticatable + # t.string :magic_link_token + # t.datetime :magic_link_sent_at + # t.integer :magic_link_requests_count, default: 0, null: false # Only if magic link requests are rate limited + # t.datetime :magic_link_first_request_at # Only if magic link requests are rate limited + ## Trackable # t.integer :sign_in_count, default: 0, null: false # t.datetime :current_sign_in_at diff --git a/lib/generators/active_record/templates/migration.rb b/lib/generators/active_record/templates/migration.rb index ad85124972..93897a37e3 100644 --- a/lib/generators/active_record/templates/migration.rb +++ b/lib/generators/active_record/templates/migration.rb @@ -14,6 +14,7 @@ def change add_index :<%= table_name %>, :email, unique: true add_index :<%= table_name %>, :reset_password_token, unique: true + # add_index :<%= table_name %>, :magic_link_token, unique: true # add_index :<%= table_name %>, :confirmation_token, unique: true # add_index :<%= table_name %>, :unlock_token, unique: true end diff --git a/lib/generators/active_record/templates/migration_existing.rb b/lib/generators/active_record/templates/migration_existing.rb index a44e5413e7..cc381e2f90 100644 --- a/lib/generators/active_record/templates/migration_existing.rb +++ b/lib/generators/active_record/templates/migration_existing.rb @@ -15,6 +15,7 @@ def self.up add_index :<%= table_name %>, :email, unique: true add_index :<%= table_name %>, :reset_password_token, unique: true + # add_index :<%= table_name %>, :magic_link_token, unique: true # add_index :<%= table_name %>, :confirmation_token, unique: true # add_index :<%= table_name %>, :unlock_token, unique: true end diff --git a/lib/generators/devise/controllers_generator.rb b/lib/generators/devise/controllers_generator.rb index d96d3d33ee..70e5eb7076 100644 --- a/lib/generators/devise/controllers_generator.rb +++ b/lib/generators/devise/controllers_generator.rb @@ -5,7 +5,7 @@ module Devise module Generators class ControllersGenerator < Rails::Generators::Base - CONTROLLERS = %w(confirmations passwords registrations sessions unlocks omniauth_callbacks).freeze + CONTROLLERS = %w(confirmations magic_links passwords registrations sessions unlocks omniauth_callbacks).freeze desc <<-DESC.strip_heredoc Create inherited Devise controllers in your app/controllers folder. diff --git a/lib/generators/devise/orm_helpers.rb b/lib/generators/devise/orm_helpers.rb index 18c8526a59..0bd007fa1f 100644 --- a/lib/generators/devise/orm_helpers.rb +++ b/lib/generators/devise/orm_helpers.rb @@ -6,7 +6,7 @@ module OrmHelpers def model_contents buffer = <<-CONTENT # Include default devise modules. Others available are: - # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable + # :confirmable, :lockable, :timeoutable, :trackable, :magic_link_authenticatable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable diff --git a/lib/generators/devise/views_generator.rb b/lib/generators/devise/views_generator.rb index bc271743cf..19fa291a2f 100644 --- a/lib/generators/devise/views_generator.rb +++ b/lib/generators/devise/views_generator.rb @@ -18,7 +18,7 @@ module ViewPathTemplates #:nodoc: # It should be fixed in future Rails releases class_option :form_builder, aliases: "-b" class_option :markerb - class_option :views, aliases: "-v", type: :array, desc: "Select specific view directories to generate (confirmations, passwords, registrations, sessions, unlocks, mailer)" + class_option :views, aliases: "-v", type: :array, desc: "Select specific view directories to generate (confirmations, magic_links, passwords, registrations, sessions, unlocks, mailer)" public_task :copy_views end @@ -30,6 +30,7 @@ def copy_views end else view_directory :confirmations + view_directory :magic_links view_directory :passwords view_directory :registrations view_directory :sessions diff --git a/lib/generators/mongoid/devise_generator.rb b/lib/generators/mongoid/devise_generator.rb index 777f3d6bc6..55e88d6e17 100644 --- a/lib/generators/mongoid/devise_generator.rb +++ b/lib/generators/mongoid/devise_generator.rb @@ -33,6 +33,12 @@ def migration_data ## Rememberable field :remember_created_at, type: Time + ## Magic link authenticatable + # field :magic_link_token, type: String + # field :magic_link_sent_at, type: Time + # field :magic_link_requests_count, type: Integer, default: 0 # Only if magic link requests are rate limited + # field :magic_link_first_request_at, type: Time # Only if magic link requests are rate limited + ## Trackable # field :sign_in_count, type: Integer, default: 0 # field :current_sign_in_at, type: Time diff --git a/lib/generators/templates/controllers/magic_links_controller.rb b/lib/generators/templates/controllers/magic_links_controller.rb new file mode 100644 index 0000000000..84f05f70e0 --- /dev/null +++ b/lib/generators/templates/controllers/magic_links_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class <%= @scope_prefix %>MagicLinksController < Devise::MagicLinksController + # GET /resource/magic_link/new + # def new + # super + # end + + # POST /resource/magic_link + # def create + # super + # end + + # GET /resource/magic_link?magic_link_token=abcdef + # def show + # super + # end + + # protected + + # The path used after sending magic link instructions + # def after_sending_magic_link_instructions_path_for(resource_name) + # super(resource_name) + # end +end diff --git a/lib/generators/templates/devise.rb b/lib/generators/templates/devise.rb index b36f281f25..3a7e2a3c58 100644 --- a/lib/generators/templates/devise.rb +++ b/lib/generators/templates/devise.rb @@ -233,6 +233,29 @@ # reset. Defaults to true, so a user is signed in automatically after a reset. # config.sign_in_after_reset_password = true + # ==> Configuration for :magic_link_authenticatable + # + # Defines which key will be used when requesting a magic link for an account + # config.magic_link_keys = [:email] + + # Time interval you can sign in with a magic link token. Magic links are + # single-use, so keep this interval short since each link signs the user in + # directly without a password. + # config.magic_link_within = 20.minutes + + # How many magic links can be requested for an account within + # `magic_link_request_period`, to prevent the email delivery from being + # spammed. Requires the magic_link_requests_count and + # magic_link_first_request_at columns (see migrations). Set to nil to + # disable rate limiting. Can also be configured per model, e.g. + # `devise :magic_link_authenticatable, magic_link_request_limit: nil`. + # config.magic_link_request_limit = 10 + + # Time period used by `magic_link_request_limit` to limit magic link + # requests. Once this period has passed since the first request, the + # counter resets. + # config.magic_link_request_period = 1.hour + # ==> Configuration for :encryptable # Allow you to use another hashing or encryption algorithm besides bcrypt (default). # You can use :sha1, :sha512 or algorithms from others authentication tools as diff --git a/lib/generators/templates/markerb/magic_link_instructions.markerb b/lib/generators/templates/markerb/magic_link_instructions.markerb new file mode 100644 index 0000000000..1f1545ace2 --- /dev/null +++ b/lib/generators/templates/markerb/magic_link_instructions.markerb @@ -0,0 +1,9 @@ +Hello <%= @resource.email %>! + +Someone has requested a link to sign in to your account. You can do this through the link below. + +[Sign in to my account](<%= magic_link_url(@resource, magic_link_token: @token) %>) + +If you didn't request this, please ignore this email. + +The link can be used only once and will expire in a few minutes. diff --git a/lib/generators/templates/simple_form_for/magic_links/new.html.erb b/lib/generators/templates/simple_form_for/magic_links/new.html.erb new file mode 100644 index 0000000000..2d9530a387 --- /dev/null +++ b/lib/generators/templates/simple_form_for/magic_links/new.html.erb @@ -0,0 +1,18 @@ +

Sign in with a magic link

+ +<%= simple_form_for(resource, as: resource_name, url: magic_link_path(resource_name), html: { method: :post }) do |f| %> + <%= f.error_notification %> + +
+ <%= f.input :email, + required: true, + autofocus: true, + input_html: { autocomplete: "email" } %> +
+ +
+ <%= f.button :submit, "Send me a magic link" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/test/controllers/url_helpers_test.rb b/test/controllers/url_helpers_test.rb index e4b6a30933..a66822b775 100644 --- a/test/controllers/url_helpers_test.rb +++ b/test/controllers/url_helpers_test.rb @@ -48,6 +48,11 @@ def assert_path_and_url(name, prepend_path = nil) assert_path_and_url :password, :edit end + test 'should alias magic_link to mapped user magic link' do + assert_path_and_url :magic_link + assert_path_and_url :magic_link, :new + end + test 'should alias confirmation to mapped user confirmation' do assert_path_and_url :confirmation assert_path_and_url :confirmation, :new diff --git a/test/integration/magic_link_authenticatable_test.rb b/test/integration/magic_link_authenticatable_test.rb new file mode 100644 index 0000000000..b62537bd51 --- /dev/null +++ b/test/integration/magic_link_authenticatable_test.rb @@ -0,0 +1,273 @@ +# frozen_string_literal: true + +require 'test_helper' + +class MagicLinkAuthenticationTest < Devise::IntegrationTest + + def visit_new_magic_link_path + visit new_user_session_path + click_link 'Sign in with a magic link' + end + + def request_magic_link(&block) + visit_new_magic_link_path + assert_response :success + assert_not warden.authenticated?(:user) + + fill_in 'email', with: 'user@test.com' + yield if block_given? + + Devise.stubs(:friendly_token).returns("abcdef") + click_button 'Send me a magic link' + end + + def sign_in_with_magic_link(token = "abcdef") + visit user_magic_link_path(magic_link_token: token) + end + + test 'authenticated user should not be able to visit magic link request page' do + sign_in_as_user + assert warden.authenticated?(:user) + + get new_user_magic_link_path + + assert_response :redirect + assert_redirected_to root_path + end + + test 'not authenticated user should be able to request a magic link' do + create_user + request_magic_link + + assert_current_url '/users/sign_in' + assert_contain 'You will receive an email with a magic link to sign in to your account in a few minutes.' + end + + test 'magic link email should be sent to the user record email' do + create_user + request_magic_link + + mail = ActionMailer::Base.deliveries.last + assert_equal ['user@test.com'], mail.to + assert_match user_magic_link_path(magic_link_token: 'abcdef'), mail.body.encoded + end + + test 'not authenticated user with invalid email should receive an error message' do + request_magic_link do + fill_in 'email', with: 'invalid.test@test.com' + end + + assert_response :success + assert_current_url '/users/magic_link' + assert_have_selector "input[type=email][value='invalid.test@test.com']" + assert_contain 'not found' + end + + test 'magic link request with email of different case should succeed when email is in the list of case insensitive keys' do + create_user(email: 'Foo@Bar.com') + + request_magic_link do + fill_in 'email', with: 'foo@bar.com' + end + + assert_current_url '/users/sign_in' + assert_contain 'You will receive an email with a magic link to sign in to your account in a few minutes.' + end + + test 'user with valid magic link token should be able to sign in' do + user = create_user + request_magic_link + sign_in_with_magic_link + + assert_current_url '/' + assert_contain 'Signed in successfully.' + assert warden.authenticated?(:user) + assert_equal user.id, warden.user(:user).id + end + + test 'magic link token should be consumed after a successful sign in' do + user = create_user + request_magic_link + sign_in_with_magic_link + + assert warden.authenticated?(:user) + assert_nil user.reload.magic_link_token + assert_nil user.reload.magic_link_sent_at + end + + test 'magic link should not be usable twice' do + create_user + request_magic_link + sign_in_with_magic_link + assert warden.authenticated?(:user) + + delete destroy_user_session_path + assert_not warden.authenticated?(:user) + + sign_in_with_magic_link + assert_not warden.authenticated?(:user) + assert_contain 'Invalid or expired magic link. Please request a new one.' + end + + test 'user with invalid magic link token should not be able to sign in' do + create_user + request_magic_link + sign_in_with_magic_link('invalid_token') + + assert_not warden.authenticated?(:user) + assert_contain 'Invalid or expired magic link. Please request a new one.' + end + + test 'user with expired magic link token should not be able to sign in' do + swap Devise, magic_link_within: 20.minutes do + user = create_user + request_magic_link + user.reload.update_attribute(:magic_link_sent_at, 21.minutes.ago) + + sign_in_with_magic_link + + assert_not warden.authenticated?(:user) + assert_contain 'Invalid or expired magic link. Please request a new one.' + end + end + + test 'user without a magic link token should not be able to visit the sign in endpoint' do + get user_magic_link_path + + assert_response :redirect + assert_redirected_to '/users/sign_in' + end + + test 'magic link token should not authenticate outside the magic link endpoint' do + create_user + request_magic_link + + get root_path(magic_link_token: 'abcdef') + + assert_not warden.authenticated?(:user) + end + + test 'locked user should not be able to sign in with a magic link' do + create_user(locked: true) + request_magic_link + sign_in_with_magic_link + + assert_not warden.authenticated?(:user) + assert_contain 'Your account is locked.' + end + + test 'unconfirmed user should not be able to sign in with a magic link' do + create_user(confirm: false) + request_magic_link + sign_in_with_magic_link + + assert_not warden.authenticated?(:user) + assert_contain 'You have to confirm your email address before continuing.' + end + + test 'magic link request with valid e-mail in JSON format should return empty and valid response' do + create_user + post user_magic_link_path(format: 'json'), params: { user: { email: 'user@test.com' } } + assert_response :success + assert_equal({}.to_json, response.body) + end + + test 'magic link request with invalid e-mail in JSON format should return errors' do + create_user + post user_magic_link_path(format: 'json'), params: { user: { email: 'invalid.test@test.com' } } + assert_response :unprocessable_entity + assert_includes response.body, '{"errors":{' + end + + test 'magic link request with invalid e-mail in JSON format should return empty and valid response in paranoid mode' do + swap Devise, paranoid: true do + create_user + post user_magic_link_path(format: 'json'), params: { user: { email: 'invalid@test.com' } } + assert_response :success + assert_equal({}.to_json, response.body) + end + end + + test 'when in paranoid mode and with an invalid e-mail, requesting a magic link should not leak if the e-mail exists in the database' do + swap Devise, paranoid: true do + visit_new_magic_link_path + fill_in 'email', with: 'arandomemail@test.com' + click_button 'Send me a magic link' + + assert_not_contain 'not found' + assert_contain 'If your email address exists in our database, you will receive an email with a magic link to sign in to your account in a few minutes.' + assert_current_url '/users/sign_in' + end + end + + test 'when in paranoid mode and with a valid e-mail, requesting a magic link should display the same message' do + swap Devise, paranoid: true do + user = create_user + visit_new_magic_link_path + fill_in 'email', with: user.email + click_button 'Send me a magic link' + + assert_contain 'If your email address exists in our database, you will receive an email with a magic link to sign in to your account in a few minutes.' + assert_current_url '/users/sign_in' + end + end + + test 'user over the magic link request limit should see an error message' do + user = create_user + user.update_attribute(:magic_link_first_request_at, Time.now.utc) + user.update_attribute(:magic_link_requests_count, User.magic_link_request_limit) + + request_magic_link + + assert_response :success + assert_current_url '/users/magic_link' + assert_contain 'Too many magic links were requested. You can request a new one in about 1 hour.' + assert_equal User.magic_link_request_limit, user.reload.magic_link_requests_count + end + + test 'user over the magic link request limit should be able to request again after the period expires' do + user = create_user + user.update_attribute(:magic_link_first_request_at, 61.minutes.ago) + user.update_attribute(:magic_link_requests_count, User.magic_link_request_limit) + + request_magic_link + + assert_current_url '/users/sign_in' + assert_contain 'You will receive an email with a magic link to sign in to your account in a few minutes.' + assert_equal 1, user.reload.magic_link_requests_count + end + + test 'when in paranoid mode, a request over the limit should not leak the rate limit error' do + swap Devise, paranoid: true do + user = create_user + user.update_attribute(:magic_link_first_request_at, Time.now.utc) + user.update_attribute(:magic_link_requests_count, User.magic_link_request_limit) + + assert_no_difference 'ActionMailer::Base.deliveries.size' do + request_magic_link + end + + assert_not_contain 'Too many magic links were requested.' + assert_contain 'If your email address exists in our database, you will receive an email with a magic link to sign in to your account in a few minutes.' + assert_current_url '/users/sign_in' + end + end + + test 'magic link request over the limit in JSON format should return errors' do + user = create_user + user.update_attribute(:magic_link_first_request_at, Time.now.utc) + user.update_attribute(:magic_link_requests_count, User.magic_link_request_limit) + + post user_magic_link_path(format: 'json'), params: { user: { email: 'user@test.com' } } + assert_response :unprocessable_entity + assert_includes response.body, 'Too many magic links were requested.' + end + + test 'after signing in with a magic link, callback is triggered' do + create_user + request_magic_link + + User.any_instance.expects(:after_magic_link_authentication) + sign_in_with_magic_link + end +end diff --git a/test/mailers/magic_link_instructions_test.rb b/test/mailers/magic_link_instructions_test.rb new file mode 100644 index 0000000000..436b648520 --- /dev/null +++ b/test/mailers/magic_link_instructions_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'test_helper' + +class MagicLinkInstructionsTest < ActionMailer::TestCase + def setup + setup_mailer + Devise.mailer = 'Devise::Mailer' + Devise.mailer_sender = 'test@example.com' + end + + def teardown + Devise.mailer = 'Devise::Mailer' + Devise.mailer_sender = 'please-change-me@config-initializers-devise.com' + end + + def user + @user ||= begin + user = create_user + user.send_magic_link_instructions + user + end + end + + def mail + @mail ||= begin + user + ActionMailer::Base.deliveries.last + end + end + + test 'email sent after requesting a magic link' do + assert_not_nil mail + end + + test 'content type should be set to html' do + assert_includes mail.content_type, 'text/html' + end + + test 'send magic link instructions to the user email' do + assert_equal [user.email], mail.to + end + + test 'set up sender from configuration' do + assert_equal ['test@example.com'], mail.from + end + + test 'set up reply to as copy from sender' do + assert_equal ['test@example.com'], mail.reply_to + end + + test 'set up subject from I18n' do + store_translations :en, devise: { mailer: { magic_link_instructions: { subject: 'Your login link' } } } do + assert_equal 'Your login link', mail.subject + end + end + + test 'subject namespaced by model' do + store_translations :en, devise: { mailer: { magic_link_instructions: { user_subject: 'User login link' } } } do + assert_equal 'User login link', mail.subject + end + end + + test 'body should have user info' do + assert_match user.email, mail.body.encoded + end + + test 'body should have link to sign in with the magic link token' do + host, port = ActionMailer::Base.default_url_options.values_at :host, :port + + if mail.body.encoded =~ %r{} + assert_equal user.magic_link_token, Devise.token_generator.digest(user.class, :magic_link_token, $1) + else + flunk "expected magic link url regex to match" + end + end + + test 'mailer sender accepts a proc' do + swap Devise, mailer_sender: proc { "another@example.com" } do + assert_equal ['another@example.com'], mail.from + end + end +end diff --git a/test/mapping_test.rb b/test/mapping_test.rb index 9d60287cd4..9c702f8dad 100644 --- a/test/mapping_test.rb +++ b/test/mapping_test.rb @@ -52,7 +52,7 @@ def fake_request(path, params = {}) end test 'has strategies depending on the model declaration' do - assert_equal [:rememberable, :database_authenticatable], Devise.mappings[:user].strategies + assert_equal [:rememberable, :magic_link_authenticatable, :database_authenticatable], Devise.mappings[:user].strategies assert_equal [:database_authenticatable], Devise.mappings[:admin].strategies end @@ -112,12 +112,14 @@ def user.devise_scope; :special_scope; end assert mapping.recoverable? assert mapping.rememberable? assert mapping.registerable? + assert mapping.magic_link_authenticatable? mapping = Devise.mappings[:admin] assert mapping.authenticatable? assert mapping.recoverable? assert mapping.lockable? assert_not mapping.omniauthable? + assert_not mapping.magic_link_authenticatable? end test 'find mapping by path' do diff --git a/test/models/magic_link_authenticatable_test.rb b/test/models/magic_link_authenticatable_test.rb new file mode 100644 index 0000000000..c9871e24a0 --- /dev/null +++ b/test/models/magic_link_authenticatable_test.rb @@ -0,0 +1,311 @@ +# frozen_string_literal: true + +require 'test_helper' + +class MagicLinkAuthenticatableTest < ActiveSupport::TestCase + + def setup + setup_mailer + end + + test 'should not generate magic link token after creating a record' do + assert_nil new_user.magic_link_token + end + + test 'should never generate the same magic link token for different users' do + magic_link_tokens = [] + 3.times do + user = create_user + user.send_magic_link_instructions + token = user.magic_link_token + assert_not_includes magic_link_tokens, token + magic_link_tokens << token + end + end + + test 'should generate a new magic link token and set the sent time' do + user = create_user + assert_nil user.magic_link_token + assert_nil user.magic_link_sent_at + + user.send_magic_link_instructions + + assert_present user.magic_link_token + assert_present user.magic_link_sent_at + end + + test 'should return the raw token from send_magic_link_instructions' do + user = create_user + raw = user.send_magic_link_instructions + + assert_present raw + assert_equal user, User.with_magic_link_token(raw) + end + + test 'should store the digested token instead of the raw one' do + user = create_user + raw = user.send_magic_link_instructions + + assert_not_equal raw, user.magic_link_token + assert_equal user.magic_link_token, Devise.token_generator.digest(User, :magic_link_token, raw) + end + + test 'should regenerate magic link token, invalidating the previous one' do + user = create_user + old_raw = user.send_magic_link_instructions + new_raw = user.send_magic_link_instructions + + assert_not_equal old_raw, new_raw + assert_nil User.with_magic_link_token(old_raw) + assert_equal user, User.with_magic_link_token(new_raw) + end + + test 'should send email with magic link instructions' do + user = create_user + + assert_difference 'ActionMailer::Base.deliveries.size' do + user.send_magic_link_instructions + end + end + + test 'should find a user to send magic link instructions' do + user = create_user + magic_link_user = User.send_magic_link_instructions(email: user.email) + assert_equal magic_link_user, user + end + + test 'should return a new record with errors if user was not found by email' do + magic_link_user = User.send_magic_link_instructions(email: 'invalid@example.com') + assert_not magic_link_user.persisted? + assert_equal 'not found', magic_link_user.errors[:email].join + end + + test 'should return a new record with errors if email is blank' do + magic_link_user = User.send_magic_link_instructions(email: '') + assert_not magic_link_user.persisted? + assert_equal "can't be blank", magic_link_user.errors[:email].join + end + + test 'should find a user to send instructions by authentication_keys' do + swap Devise, authentication_keys: [:username, :email] do + user = create_user + magic_link_user = User.send_magic_link_instructions(email: user.email) + assert_equal magic_link_user, user + end + end + + test 'should require all magic_link_keys' do + swap Devise, magic_link_keys: [:username, :email] do + user = create_user + magic_link_user = User.send_magic_link_instructions(email: user.email) + assert_not magic_link_user.persisted? + assert_equal "can't be blank", magic_link_user.errors[:username].join + end + end + + test 'should find a user by email case-insensitively to send magic link instructions' do + user = create_user(email: 'MagicUser@example.com') + magic_link_user = User.send_magic_link_instructions(email: 'magicuser@example.com') + assert_equal magic_link_user, user + end + + test 'should not send email to a user not found' do + assert_no_difference 'ActionMailer::Base.deliveries.size' do + User.send_magic_link_instructions(email: 'invalid@example.com') + end + end + + test 'magic link period should be valid within the configured window' do + swap Devise, magic_link_within: 20.minutes do + user = create_user + user.send_magic_link_instructions + + assert user.magic_link_period_valid? + + user.magic_link_sent_at = 21.minutes.ago + assert_not user.magic_link_period_valid? + end + end + + test 'magic link period should not be valid if token was never sent' do + user = create_user + assert_not user.magic_link_period_valid? + end + + test 'magic link period can be configured per model' do + swap Devise, magic_link_within: 20.minutes do + swap_model_config User, magic_link_within: 1.hour do + user = create_user + user.send_magic_link_instructions + user.magic_link_sent_at = 30.minutes.ago + + assert user.magic_link_period_valid? + end + end + end + + test 'consume_magic_link_token! should clear the token and sent time' do + user = create_user + raw = user.send_magic_link_instructions + + user.consume_magic_link_token! + + assert_nil user.magic_link_token + assert_nil user.magic_link_sent_at + assert_nil User.with_magic_link_token(raw) + end + + test 'with_magic_link_token should return nil for an unknown token' do + assert_nil User.with_magic_link_token('invalid_token') + end + + test 'should clear magic link token if changing email' do + user = create_user + user.send_magic_link_instructions + assert_present user.magic_link_token + + user.email = 'another@example.com' + user.save! + assert_nil user.magic_link_token + assert_nil user.magic_link_sent_at + end + + test 'should clear magic link token if changing password' do + user = create_user + user.send_magic_link_instructions + assert_present user.magic_link_token + + user.password = '123456789' + user.password_confirmation = '123456789' + user.save! + assert_nil user.magic_link_token + end + + test 'should not clear magic link token when updating unrelated attributes' do + user = create_user + user.send_magic_link_instructions + assert_present user.magic_link_token + + user.username = 'another_username' + user.save! + assert_present user.magic_link_token + end + + test 'magic_link_keys defaults to email' do + assert_equal [:email], User.magic_link_keys + end + + test 'should allow magic link requests up to the limit within the period' do + swap Devise, magic_link_request_limit: 3, magic_link_request_period: 1.hour do + user = create_user + + 3.times do + assert user.send_magic_link_instructions + end + assert_equal 3, user.magic_link_requests_count + end + end + + test 'should not send a magic link over the request limit and add an error' do + swap Devise, magic_link_request_limit: 2, magic_link_request_period: 1.hour do + user = create_user + 2.times { user.send_magic_link_instructions } + + assert_no_difference 'ActionMailer::Base.deliveries.size' do + assert_equal false, user.send_magic_link_instructions + end + + assert_equal ['Too many magic links were requested. You can request a new one in about 1 hour.'], + user.errors.full_messages + end + end + + test 'should not regenerate the token when the request is over the limit' do + swap Devise, magic_link_request_limit: 1 do + user = create_user + raw = user.send_magic_link_instructions + + user.send_magic_link_instructions + assert_equal user, User.with_magic_link_token(raw) + end + end + + test 'should reset the request counter after the period expires' do + swap Devise, magic_link_request_limit: 2, magic_link_request_period: 1.hour do + user = create_user + 2.times { user.send_magic_link_instructions } + assert_equal false, user.send_magic_link_instructions + + user.update_attribute(:magic_link_first_request_at, 61.minutes.ago) + + assert user.send_magic_link_instructions + assert_equal 1, user.magic_link_requests_count + end + end + + test 'magic_link_request_limit_exceeded? reflects the current state' do + swap Devise, magic_link_request_limit: 1, magic_link_request_period: 1.hour do + user = create_user + assert_not user.magic_link_request_limit_exceeded? + + user.send_magic_link_instructions + assert user.magic_link_request_limit_exceeded? + + user.update_attribute(:magic_link_first_request_at, 61.minutes.ago) + assert_not user.reload.magic_link_request_limit_exceeded? + end + end + + test 'should persist the request counter along with the token' do + user = create_user + user.send_magic_link_instructions + + user.reload + assert_equal 1, user.magic_link_requests_count + assert_present user.magic_link_first_request_at + end + + test 'should not rate limit magic link requests when the limit is disabled' do + swap Devise, magic_link_request_limit: nil do + user = create_user + + assert_difference 'ActionMailer::Base.deliveries.size', 3 do + 3.times { assert user.send_magic_link_instructions } + end + assert_not user.magic_link_request_limit_exceeded? + assert_equal 0, user.magic_link_requests_count + end + end + + test 'magic link request limit can be configured per model' do + swap Devise, magic_link_request_limit: 1 do + swap_model_config User, magic_link_request_limit: 2 do + user = create_user + + 2.times { assert user.send_magic_link_instructions } + assert_equal false, user.send_magic_link_instructions + end + end + end + + test 'magic link rate limiting can be disabled per model with nil' do + swap Devise, magic_link_request_limit: 1 do + swap_model_config User, magic_link_request_limit: nil do + user = create_user + + 3.times { assert user.send_magic_link_instructions } + assert_not user.magic_link_request_limit_exceeded? + end + end + end + + test 'class level send_magic_link_instructions should return the record with errors when over the limit' do + swap Devise, magic_link_request_limit: 1 do + user = create_user + user.send_magic_link_instructions + + magic_link_user = User.send_magic_link_instructions(email: user.email) + assert_present magic_link_user.errors[:base] + end + end +end diff --git a/test/rails_app/app/mongoid/user.rb b/test/rails_app/app/mongoid/user.rb index 7e5b2b381c..8a106d3eb7 100644 --- a/test/rails_app/app/mongoid/user.rb +++ b/test/rails_app/app/mongoid/user.rb @@ -21,6 +21,12 @@ class User ## Rememberable field :remember_created_at, type: Time + ## Magic link authenticatable + field :magic_link_token, type: String + field :magic_link_sent_at, type: Time + field :magic_link_requests_count, type: Integer, default: 0 + field :magic_link_first_request_at, type: Time + ## Trackable field :sign_in_count, type: Integer, default: 0 field :current_sign_in_at, type: Time diff --git a/test/rails_app/db/migrate/20100401102949_create_tables.rb b/test/rails_app/db/migrate/20100401102949_create_tables.rb index 8d46b3e5c4..350e5873a9 100644 --- a/test/rails_app/db/migrate/20100401102949_create_tables.rb +++ b/test/rails_app/db/migrate/20100401102949_create_tables.rb @@ -17,6 +17,12 @@ def self.up ## Rememberable t.datetime :remember_created_at + ## Magic link authenticatable + t.string :magic_link_token + t.datetime :magic_link_sent_at + t.integer :magic_link_requests_count, default: 0, null: false + t.datetime :magic_link_first_request_at + ## Trackable t.integer :sign_in_count, default: 0 t.datetime :current_sign_in_at diff --git a/test/rails_app/db/schema.rb b/test/rails_app/db/schema.rb index c435f6b96e..aea8aa10e8 100644 --- a/test/rails_app/db/schema.rb +++ b/test/rails_app/db/schema.rb @@ -39,6 +39,10 @@ t.string "reset_password_token" t.datetime "reset_password_sent_at" t.datetime "remember_created_at" + t.string "magic_link_token" + t.datetime "magic_link_sent_at" + t.integer "magic_link_requests_count", default: 0, null: false + t.datetime "magic_link_first_request_at" t.integer "sign_in_count", default: 0 t.datetime "current_sign_in_at" t.datetime "last_sign_in_at" diff --git a/test/rails_app/lib/shared_user.rb b/test/rails_app/lib/shared_user.rb index e037fe867a..063c49ebaf 100644 --- a/test/rails_app/lib/shared_user.rb +++ b/test/rails_app/lib/shared_user.rb @@ -5,7 +5,7 @@ module SharedUser included do devise :database_authenticatable, :confirmable, :lockable, :recoverable, - :registerable, :rememberable, :timeoutable, + :registerable, :rememberable, :timeoutable, :magic_link_authenticatable, :trackable, :validatable, :omniauthable, password_length: 7..72, reconfirmable: false diff --git a/test/routes_test.rb b/test/routes_test.rb index 20ba311727..b101a10f81 100644 --- a/test/routes_test.rb +++ b/test/routes_test.rb @@ -34,6 +34,20 @@ class DefaultRoutingTest < ActionController::TestCase assert_recognizes({controller: 'devise/confirmations', action: 'show'}, {path: 'users/confirmation', method: :get}) end + test 'map new user magic link' do + assert_recognizes({controller: 'devise/magic_links', action: 'new'}, 'users/magic_link/new') + assert_named_route "/users/magic_link/new", :new_user_magic_link_path + end + + test 'map create user magic link' do + assert_recognizes({controller: 'devise/magic_links', action: 'create'}, {path: 'users/magic_link', method: :post}) + assert_named_route "/users/magic_link", :user_magic_link_path + end + + test 'map show user magic link' do + assert_recognizes({controller: 'devise/magic_links', action: 'show'}, {path: 'users/magic_link', method: :get}) + end + test 'map new user password' do assert_recognizes({controller: 'devise/passwords', action: 'new'}, 'users/password/new') assert_named_route "/users/password/new", :new_user_password_path