August 11, 2019
•Last updated November 5, 2023
Extending Devise - Login With Username or Email
Welcome to another installment of my Let's Build with Ruby on Rails - Extending Devise series within a series. This post will teach you how to allow a user to login with either a username or email using the Devise gem.
Getting Started
In the video screencast, I reference my kickoff_tailwind Ruby on Rails application template. You can use that as I have or start from scratch. My template sets up Devise automatically so there's less to configure upfront. I also add a new username
and name
column to the User
database table which we'll reference in this guide. If you're not using the template be sure to at least add a username
column on your table.
That might look like this:
$ rails generate migration add_username_to_users username:string
This command generates a new migration responsible for adding a username
column to the users
database table.
Creating a new :login
attribute inside the User
model
We need a way to denote if the new login id is either a username or email. Devise ships using only email
as a valid default for creating a new session (logging in) per user. To extend this we need to tweak a bit of the inner-workings of Devise to accommodate. The user model becomes the following:
# app/models/user.rb
class User < ApplicationRecord
attr_accessor :login
# "getter"
# def login
# @login
# end
# "setter"
# def login=(str)
# @login = str
# end
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
def self.find_for_database_authentication warden_condition
conditions = warden_condition.dup
login = conditions.delete(:login)
where(conditions).where(
["lower(username) = :value OR lower(email) = :value",
{ value: login.strip.downcase}]).first
end
end
I created a new attr_accessor
called :login
. This is a shorthand for the comments you see following that line of code above. It essentially creates a getter
and a setter
for you so you can keep your models cleaner. This is done so often that the shorthand was a great addition to ruby.
At the bottom of the class, you'll find a new method self.find_for_database_authentication
which extends Devise to query via warden. This uses some SQL to query for either the username
or email
fields given one or the other is supplied during form submission.
Permitting new fields
With the brains of the logic hashed out we can head to the controller layer to allow the :login
attributes to submit securely.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
before_action :configure_permitted_parameters, if: :devise_controller?
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up,
keys: [:username, :name, :email, :password, :password_confirmation])
devise_parameter_sanitizer.permit(:sign_in,
keys: [:login, :password, :password_confirmation])
devise_parameter_sanitizer.permit(:account_update,
keys: [:username, :name, :email, :password_confirmation, :current_password])
end
end
Above I amend my application_controller.rb
file to include newly configured parameters. This is essentially white-listing those fields so Rails knows to accept those into the session/database. I've added the :login
attribute to the :sign_in
permission respectively.
Updating the view layer
With those core changes in place above we can extend our view layer to include them.
<!-- app/views/devise/sessions/new.html.erb-->
<h2 class="heading text-4xl font-bold pt-4 mb-8">Log in</h2>
<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
<div class="mb-6">
<%= f.label :login, class:"label" %>
<%= f.text_field :login, autofocus: true, class: "input" %>
</div>
<!-- additional fields -->
<% end %>
If you haven't already added the username
fields to your Sign up views you can do that as well.
<!-- app/views/devise/registrations/edit.html.erb-->
<h2 class="heading text-4xl font-bold pt-4 mb-8">Edit <%= resource_name.to_s.humanize %></h2>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="mb-6">
<%= f.label :username, class:"label" %>
<%= f.text_field :username, autofocus: true, class:"input" %>
</div>
<!-- additional fields -->
<% end %>
<!-- app/views/devise/registrations/new.html.erb-->
<h2 class="heading text-4xl font-bold pt-4 mb-8">Edit <%= resource_name.to_s.humanize %></h2>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="mb-6">
<%= f.label :username, class:"label" %>
<%= f.text_field :username, autofocus: true, class:"input" %>
</div>
<!-- additional fields -->
<% end %>
And then finally I updated my application layout to accommodate the logged in and logged out states so it's easier to log out.
<!DOCTYPE html>
<html>
<head>
<title>Kickoff</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<meta name="viewport" content="width=device-width, initial-scale=1">
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
<body class="bg-white">
<% flash.each do |type, message| %>
<% if type == "alert" %>
<div class="bg-red-500">
<div class="container px-2 mx-auto py-4 text-white text-center font-medium font-sans"><%= message %></div>
</div>
<% end %>
<% if type == "notice" %>
<div class="bg-green-500">
<div class="container px-2 mx-auto py-4 text-white text-center font-medium font-sans"><%= message %></div>
</div>
<% end %>
<% end %>
<header class="mb-4">
<nav class="flex items-center justify-between flex-wrap bg-gray-100 py-3 lg:px-10 px-3 text-gray-700 border-b border-gray-400">
<div class="flex items-center flex-no-shrink mr-6">
<%= link_to "Kickoff", root_path, class:"link text-xl tracking-tight font-semibold" %>
</div>
<div class="block lg:hidden">
<button class="flex items-center px-3 py-2 border rounded text-grey border-gray-500 hover:text-gray-600 hover:border-gray-600">
<svg class="fill-current h-3 w-3" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><title>Menu</title><path d="M0 3h20v2H0V3zm0 6h20v2H0V9zm0 6h20v2H0v-2z"/></svg>
</button>
</div>
<div class="w-full block lg:flex-1 lg:flex items-center text-center lg:text-left">
<div class="lg:flex-grow">
<%= link_to "Basic Link", "#", class: "block mt-4 lg:inline-block lg:mt-0 lg:mr-4 mb-2 lg:mb-0 link" %>
</div>
<div class="w-full block lg:flex lg:flex-row lg:flex-1 mt-2 lg:mt-0 text-center lg:text-left lg:justify-end items-center">
<% if user_signed_in? %>
<p class="lg:mr-2 px-4">Welcome, <%= current_user.username %></p>
<%= link_to "Log out", destroy_user_session_path, method: :delete, class:"btn btn-default mb-2 lg:mr-2 lg:mb-0 block" %>
<% else %>
<%= link_to "Login", new_user_session_path, class:"btn btn-default mb-2 lg:mr-2 lg:mb-0 block" %>
<%= link_to "Sign Up", new_user_registration_path, class:"btn btn-default block" %>
<% end %>
</div>
</div>
</nav>
</header>
<main class="lg:px-10 px-4">
<%= content_for?(:content) ? yield(:content) : yield %>
</main>
</body>
</html>
Closing thoughts
Hopefully, this proved to be useful! I believe this will be a way forward for me in my own apps. People are forgetful and sometimes remembering their username or email can prove to be a challenge. One issue I didn't account for here is if a user forgot their username. That's often a pattern you see out in the wild. Devise (I don't think) supports this type of pattern by default but It seems to mimic the "Forgot password" approach. Perhaps I'll dig into how to resolve this in a future installment.
If you have feedback, questions, or anything else please let me know in the comments. Thanks for following along!
The Series So Far
- Let's Build: With Ruby on Rails - Extending Devise Series - Adding Custom Fields
- Let's Build: With Ruby on Rails - Extending Devise Series - Confirmation Emails
- Let's Build: With Ruby on Rails - Extending Devise Series - Custom Routing
Shameless plug time
I have a new course called Hello Rails. Hello Rails is a modern course designed to help you start using and understanding Ruby on Rails fast. If you're a novice when it comes to Ruby or Ruby on Rails I invite you to check out the site. The course will be much like these builds but a super more in-depth version with more realistic goals and deliverables. Sign up to get notified today!
Follow @hello_rails and myself @justalever on Twitter.
Categories
Collection
Part of the Ruby on Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.