Add authentication to your Rails app without all the icky-ness of passwords.
Add the passwordless
gem to your Gemfile
:
gem 'passwordless'
Install it and copy over the migrations:
$ bundle
$ bin/rails passwordless:install:migrations
Passwordless creates a single model called Passwordless::Session
. It doesn't come with its own User
model, it expects you to create one:
$ bin/rails generate model User email
Then specify which field on your User
record is the email field with:
class User < ApplicationRecord
validates :email, presence: true, uniqueness: { case_sensitive: false }
passwordless_with :email # <-- here!
end
Finally, mount the engine in your routes:
Rails.application.routes.draw do
passwordless_for :users
# other routes
end
Passwordless doesn't give you current_user
automatically. Here's how you could add it:
class ApplicationController < ActionController::Base
include Passwordless::ControllerHelpers # <-- This!
# ...
helper_method :current_user
private
def current_user
@current_user ||= authenticate_by_cookie(User)
end
def require_user!
return if current_user
redirect_to root_path, flash: {error: 'You are not worthy!'}
end
end
Et voilร :
class VerySecretThingsController < ApplicationController
before_action :require_user!
def index
@things = current_user.very_secret_things
end
end
Override passwordless
' bundled views by adding your own. passwordless
has 2 action views and 1 mailer view:
# the form where the user inputs their email address
app/views/passwordless/sessions/new.html.erb
# shown after a user requests a magic link
app/views/passwordless/sessions/create.html.erb
# the mail with the magic link that gets sent
app/views/passwordless/mailer/magic_link.text.erb
See the bundled views.
Because your User
record is like any other record, you create one like you normally would. Passwordless provides a helper method to sign in the created user after it is saved โ like so:
class UsersController < ApplicationController
include Passwordless::ControllerHelpers # <-- This!
# (unless you already have it in your ApplicationController)
def create
@user = User.new user_params
if @user.save
sign_in @user # <-- This!
redirect_to @user, flash: {notice: 'Welcome!'}
else
render :new
end
end
# ...
end
By default Passwordless generates tokens using SecureRandom.urlsafe_base64
but you can change that by setting Passwordless.token_generator
to something else that responds to call(session)
eg.:
Passwordless.token_generator = -> (session) {
"probably-stupid-token-#{session.user_agent}-#{Time.current}"
}
Session is going to keep generating tokens until it finds one that hasn't been used yet. So be sure to use some kind of method where matches are unlikely.
Token timeout is the time by which the sign in token is invalidated. Post the timeout, the token cannot be used to sign-in to the app and the user would need to request it again.
Session expiry is the expiration time of the session of a logged in user. Once this is expired, user would need to log back in to create a new session.
By default, sign in tokens generated by Passwordless are made invalid after 1.hour
from the time they are generated. If you wish you can override this and supply your custom Proc function that will return a valid datetime object. Make sure the generated time is in the future.
Make sure to use a
.call
able object, like a proc or lambda as it will be called everytime a session is created.
Passwordless.timeout_at = lambda { 2.hours.from_now }
Session expiry is the time when the actual session is itself expired, i.e. users will be logged out and has to sign back in post this expiry time. By default, sessions are valid for 1.year
from the time they are generated. You can override by providing your custom Proc function that returns a datetime object.
Make sure to use a
.call
able object, like a proc or lambda as it will be called everytime a session is created.
Passwordless.expires_at = lambda { 24.hours.from_now }
By default Passwordless will redirect back to where the user wanted to go if it knows where that is, so you'll have to help it. Passwordless::ControllerHelpers
provide a method for this:
class ApplicationController < ActionController::Base
include Passwordless::ControllerHelpers # <-- Probably already have this!
# ...
def require_user!
return if current_user
save_passwordless_redirect_location!(User) # <-- here we go!
redirect_to root_path, flash: {error: 'You are not worthy!'}
end
end
This can be turned off with Passwordless.redirect_back_after_sign_in = false
but if you just don't save the previous destination, you'll be fine.
By default, Passwordless uses the resource name given to passwordless_for
to generate its routes and helpers.
passwordless_for :users
# <%= users.sign_in_path %> # => /users/sign_in
passwordless_for :users, at: '/', as: :auth
# <%= auth.sign_in_path %> # => /sign_in
Also be sure to specify ActionMailer's default_url_options.host
.
By default, magic link will send by email. You can customize this method. For example, you can send magic link via SMS.
config/initializers/passwordless.rb
Passwordless.after_session_save = lambda do |session|
# Default behavior is
# Mailer.magic_link(session).deliver_now
# You can change behavior to do something with session model. For example,
# session.authenticatable.send_sms
end
You can access user model through authenticatable.
By default passwordless
uses the passwordless_with
column to case insensitively fetch the resource.
You can override this and provide your own customer fetcher by defining a class method fetch_resource_for_passwordless
in your passwordless model. The method will be called with the downcased email and should return an ActiveRecord
instance of the model.
Example time:
Let's say we would like to fetch the record and if it doesn't exist, create automatically.
class User < ApplicationRecord
def self.fetch_resource_for_passwordless(email)
find_or_create_by(email: email)
end
end
There's no reason that this approach should be less secure than the usual username/password combo. In fact this is most often a more secure option, as users don't get to choose the weak passwords they still use. In a way this is just the same as having each user go through "Forgot password" on every login.
But be aware that when everyone authenticates via emails you send, the way you send those mails becomes a weak spot. Email services usually provide a log of all the mails you send so if your app's account is compromised, every user in the system is as well. (This is the same for "Forgot password".) Reddit was compromised using this method.
Ideally you should set up your email provider to not log these mails. And be sure to turn on 2-factor auth if your provider supports it.
- OTP JWT -- Passwordless JSON Web Tokens
MIT