Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions app/controllers/devise/magic_links_controller.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions app/mailers/devise/mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions app/views/devise/magic_links/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<h2>Sign in with a magic link</h2>

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

<div class="field">
<p><%= f.label :email %></p>
<p><%= f.email_field :email, autofocus: true, autocomplete: "email" %></p>
</div>

<div class="actions">
<%= f.submit "Send me a magic link" %>
</div>
<% end %>

<%= render "devise/shared/links" %>
8 changes: 8 additions & 0 deletions app/views/devise/mailer/magic_link_instructions.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<p>Hello <%= @resource.email %>!</p>

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

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

<p>If you didn't request this, please ignore this email.</p>
<p>The link can be used only once and will expire in a few minutes.</p>
4 changes: 4 additions & 0 deletions app/views/devise/shared/_links.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
<p><%= link_to "Forgot your password?", new_password_path(resource_name) %></p>
<% end %>

<%- if devise_mapping.magic_link_authenticatable? && controller_name != 'magic_links' %>
<p><%= link_to "Sign in with a magic link", new_magic_link_path(resource_name) %></p>
<% end %>

<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
<p><%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %></p>
<% end %>
Expand Down
9 changes: 9 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions lib/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/devise/models/authenticatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading