Skip to content

Commit

Permalink
Enable combined use of :database_authenticatable in the same resource
Browse files Browse the repository at this point in the history
Co-authored-by: Fabian Schwahn <[email protected]>
  • Loading branch information
abevoelker and fschwahn committed Sep 14, 2023
1 parent c0f0abc commit adc78bb
Show file tree
Hide file tree
Showing 16 changed files with 646 additions and 7 deletions.
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
### Enhancements

* Tokenization encoding/decoding is now fully customizable
* Tokenizer encoding now supports extra metadata ([#27][] - thanks [@fastjames][] and [@elucid][]!)
* Tokenizer encoding now supports extra metadata ([#27] - thanks [@fastjames] and [@elucid]!)
* Tokenizer encoding now supports `:expires_at` option ([#19], [#21] - thanks [@joeyparis] / [@JoeyLeadJig] and [@bvsatyaram]!)
* Turbo is now properly supported ([#23], [#33] - thanks [@iainbeeston] and [@til]!)
* Signed GlobalID tokenization supported ([#22])
* Concurrent use of password auth (`:database_authenticatable` strategy) now supported ([#13] - thanks [@fschwahn]!)
* More thorough integration testing using a dummy Rails app
* Added a Rails engine to solve loading issues and tidy up file structuring
* `Passwordless::SessionsController` now uses gem source instead of needing to be generated from a template
Expand All @@ -24,12 +25,14 @@

[@bvsatyaram]: https://github.com/bvsatyaram
[@fastjames]: https://github.com/fastjames
[@fschwahn]: https://github.com/fschwahn
[@elucid]: https://github.com/elucid
[@iainbeeston]: https://github.com/iainbeeston
[@joeyparis]: https://github.com/joeyparis
[@JoeyLeadJig]: https://github.com/JoeyLeadJig
[@til]: https://github.com/til

[#13]: https://github.com/abevoelker/devise-passwordless/issues/13
[#19]: https://github.com/abevoelker/devise-passwordless/pull/19
[#21]: https://github.com/abevoelker/devise-passwordless/pull/21
[#22]: https://github.com/abevoelker/devise-passwordless/issues/22
Expand Down
117 changes: 117 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ A passwordless a.k.a. "magic link" login strategy for [Devise][]

* No database changes needed - magic links are stateless tokens
* [Choose your token encoding algorithm or easily write your own](#tokenizers)
* [Can be combined with traditional password authentication in the same model](#combining-password-and-passwordless-auth-in-the-same-model)
* [Supports multiple user (resource) types](#multiple-user-resource-types)
* All the goodness of Devise!

Expand Down Expand Up @@ -405,6 +406,122 @@ app/views/users/
app/views/admins/
```

## Combining password and passwordless auth in the same model

It is possible to use both traditional password authentication (i.e. the
`:database_authenticatable` strategy) alongside magic link authentication in
the same model:

```ruby
# app/models/user.rb
class User < ApplicationRecord
devise :database_authenticatable, :magic_link_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
end
```

How you end up implementing it will be highly dependent on your use case. By
default, all password validations will still run - so on registration, users
will have to provide passwords - but they'll be able to log in via either
password OR magic link (you'll have to customize your routes and views to
make the separate paths accessible).

Here's an example routes file of that scenario (a separate namespace is
needed because the password vs. passwordless paths use different sessions
controllers):

```ruby
devise_for :users
namespace "passwordless" do
devise_for :users,
controllers: { sessions: "devise/passwordless/sessions" }
end
```

Visiting `/users/sign_in` will lead to a password sign in, while
`/passwordless/users/sign_in` will lead to the magic link sign in flow
(you'll need to [generate the necessary Devise views](#scoped-views)
to support the different sign-in forms).

### Disabling password authentication or magic link authentication

Rather than *all* your users having access to *both* authentication methods,
it may be the case that you want *some* users to use magic links, *some*
to use passwords, or some combination between the two.

This can be managed by defining some methods that disable the relevant
authentication strategy and determine the failure message. Here are
examples for both:

### Disabling password authentication

Let's say you want to disable password authentication for everyone except
people named Bob:

```ruby
class User < ApplicationRecord
# devise :database_authenticatable, :magic_link_authenticatable, ...
def first_name_bob?
self.first_name.downcase == "bob"
end
# The `super` is important in the following two methods as other
# auth strategies chain onto these methods:

def active_for_authentication?
super && first_name_bob?
end

def inactive_message
first_name_bob? ? super : :first_name_not_bob
end
end
```

Then, you add this to your `devise.yml` to customize the error message:

```yaml
devise:
failure:
first_name_not_bob: "Sorry, only Bobs may log in using their password. Try magic link login instead."
```
Now, when users not named Bob try to log in with their password, it'll fail with
your custom failure message.
### Disabling passwordless / magic link authentication
Disabling magic link authentication is a similar process, just with different
method names:
```ruby
class User < ApplicationRecord
# devise :database_authenticatable, :magic_link_authenticatable, ...

def first_name_alice?
self.first_name.downcase == "alice"
end

# The `super` is actually not important at the moment for these, but if
# any future Devise strategies were to extend this one, they will be.

def active_for_magic_link_authentication?
super && first_name_alice?
end

def magic_link_inactive_message
first_name_alice? ? super : :first_name_not_alice_magic_link
end
end
```

```yaml
devise:
failure:
first_name_not_alice_magic_link: "Sorry, only Alices may log in using magic links. Try password login instead."
```
## Compatibility with other Devise strategies
If using the `:rememberable` strategy for "remember me" functionality, you'll need to add a `remember_token` column to your resource, as by default that strategy assumes you're using a password auth strategy and relies on comparing the password's salt to validate cookies:
Expand Down
10 changes: 10 additions & 0 deletions lib/devise/hooks/magic_link_authenticatable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

# Deny user access when magic link authentication is disabled
Warden::Manager.after_set_user do |record, warden, options|
if record && record.respond_to?(:active_for_magic_link_authentication?) && !record.active_for_magic_link_authentication?
scope = options[:scope]
warden.logout(scope)
throw :warden, scope: scope, message: record.magic_link_inactive_message
end
end
38 changes: 33 additions & 5 deletions lib/devise/models/magic_link_authenticatable.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
require 'devise/strategies/magic_link_authenticatable'
require 'devise/hooks/magic_link_authenticatable'

module Devise
module Models
module MagicLinkAuthenticatable
extend ActiveSupport::Concern

def password_required?
false
# Models using the :database_authenticatable strategy will already
# have #password_required? and #password defined - we will defer
# to those methods if they already exist so that people can use
# both strategies together. Otherwise, for :magic_link_authenticatable-
# only users, we define them in order to disable password validations:

unless instance_methods.include?(:password_required?)
def password_required?
false
end
end

# Not having a password method breaks the :validatable module
def password
nil
unless instance_methods.include?(:password)
# Not having a #password method breaks the :validatable module
#
# NOTE I proposed a change to Devise to fix this:
# https://github.com/heartcombo/devise/issues/5346#issuecomment-822022834
# As of yet it hasn't been accepted due to unknowns of the legacy code's purpose
def password
nil
end
end

def encode_passwordless_token(*args, **kwargs)
Expand All @@ -36,6 +51,19 @@ def send_magic_link(remember_me: false, **kwargs)
def after_magic_link_authentication
end

# Set this to false to disable magic link auth for this model instance.
# Magic links will still be generated by the sign-in page, but visiting
# them will instead display an error message.
def active_for_magic_link_authentication?
true
end

# This method determines which error message to display when magic link
# auth is disabled for this model instance.
def magic_link_inactive_message
:magic_link_invalid
end

protected

module ClassMethods
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class CombinedUser < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :magic_link_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<h2>Edit <%= resource_name.to_s.humanize %></h2>

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

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

<% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
<div>Currently waiting confirmation for: <%= resource.unconfirmed_email %></div>
<% end %>

<div class="field">
<%= f.label :password %> <i>(leave blank if you don't want to change it)</i><br />
<%= f.password_field :password, autocomplete: "new-password" %>
<% if @minimum_password_length %>
<br />
<em><%= @minimum_password_length %> characters minimum</em>
<% end %>
</div>

<div class="field">
<%= f.label :password_confirmation %><br />
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>

<div class="field">
<%= f.label :current_password %> <i>(we need your current password to confirm your changes)</i><br />
<%= f.password_field :current_password, autocomplete: "current-password" %>
</div>

<div class="actions">
<%= f.submit "Update" %>
</div>
<% end %>

<h3>Cancel my account</h3>

<div>Unhappy? <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?" }, method: :delete %></div>

<%= link_to "Back", :back %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<h2>Sign up</h2>

<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
<%= render "combined_users/shared/error_messages", resource: resource %>

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

<div class="field">
<%= f.label :password %>
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em>
<% end %><br />
<%= f.password_field :password, autocomplete: "new-password" %>
</div>

<div class="field">
<%= f.label :password_confirmation %><br />
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>

<div class="actions">
<%= f.submit "Sign up" %>
</div>
<% end %>
<%= render "combined_users/shared/links" %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<h2>Log in</h2>

<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>

<div class="field">
<%= f.label :password %><br />
<%= f.password_field :password, autocomplete: "current-password" %>
</div>

<% if devise_mapping.rememberable? %>
<div class="field">
<%= f.check_box :remember_me %>
<%= f.label :remember_me %>
</div>
<% end %>

<div class="actions">
<%= f.submit "Log in" %>
</div>
<% end %>
<%= render "combined_users/shared/links" %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<% if resource.errors.any? %>
<div id="error_explanation" data-turbo-cache="false">
<h2>
<%= I18n.t("errors.messages.not_saved",
count: resource.errors.count,
resource: resource.class.model_name.human.downcase)
%>
</h2>
<ul>
<% resource.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<%- if controller_name != 'sessions' %>
<%= link_to "Log in", new_session_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<%= link_to "Sign up", new_registration_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
<%= link_to "Forgot your password?", new_password_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
<%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
<%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.omniauthable? %>
<%- resource_class.omniauth_providers.each do |provider| %>
<%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false } %><br />
<% end %>
<% end %>
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,11 @@
<span>(not signed in)</span>
<% end %>
</p>

<p class="combined_user">CombinedUser:
<% if current_combined_user %>
<span class="email"><%= current_combined_user.email %></span>
<% else %>
<span>(not signed in)</span>
<% end %>
</p>
Loading

0 comments on commit adc78bb

Please sign in to comment.