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 @@ +
<%= f.label :email %>
+<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
+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 @@ +