How to Customize Devise Authentication with Active Storage

Written by abdelp | Published 2020/11/28
Tech Story Tags: ruby-on-rails | devise | active-storage | cloudinary | authentication | direct-upload | programming | coding

TLDRvia the TL;DR App

It is really difficult to imagine an application without a very secure authentication module, they vary from one to other, but almost always having common components, like a form to introduce a user name or email, their password, maybe some social media authentication, even biometric inputs.
It is a good practice to understand what are the things you should consider to have a secure and complete authentication module. Once, you acknowledge the security rules you need to implement and the users’ necessities, instead of always implementing this from scratch you can create a template or use, in the case of Rails, a specific Gem for this, like Devise.
Devise is one of the most used Gems for authentication, and it’s very flexible to adapt it to your necessities. In this tutorial, I’m aiming to show you how you can integrate one of the other most common features with user authentication, which is to associate its Model with its profile image.
Associating a Model with an image it’s pretty straightforward in Rails thanks to the Direct uploads feature of the Active Storage module, you can use many storage services for it, like Amazon S3, Google Cloud, Microsoft Azure, among others. In this tutorial, we are going to use Cloudinary.
Cloudinary is a free storage service, till a certain point as almost any other service, that offers what we need to store our users’ profile images. It only needs you to create an account and download the credentials assigned to it.
That being said, let’s start by specificating the tools we are going to use:
$ lsb_release -a
Ubuntu 18.04.4 LTS
$ rails -v
Rails 6.0.3.1
$ ruby --version
ruby 2.6.5p114 (2019–10–01 revision 67812) [x86_64-linux]
Let’s create our project.
rails new custom-authentication -T
The
-T
argument is not important, it’s just to skip test files of rails, we are not going to use them for this tutorial.
Check that everything went well with the installation in the logs, and continue.
Go inside the root of the project, and open the
Gemfile
. We are going to need the gem for our storage service Cloudinary:
# Gemfile

gem 'cloudinary', '~> 1.18', '>= 1.18.1'
Following the setup instructions from Cloudinary’s github repo:
We install the gem:
bundle install
If you already signed up and are logged in you can download your cloudinary.yml file, otherwise, go to their signup page
You’re going to receive a verification email, after verifying it, you’re ready to download your cloudinary.yml credentials file.
Put the downloaded file in your
/config
folder. If you’re using a version control tool like git, you should ignore that file, it’s containing your private credentials, so be careful pushing it.
After that, we can install Active Storage, because it's not coming preinstalled by default:
rails active_storage:install
This is going to create a migration file with the necessary structure to associate the files with their corresponding records. So, let’s migrate it:
rails db:migrate
Now, let’s proceed with our Devise Gem, add it to the Gemfile:
# Gemfile

# ...

gem 'devise', '~> 4.7', '>= 4.7.3'
Install the gem:
bundle install
Run Devise's generator:
rails g devise:install
It will display  a set of instructions on the console:
The first one:
  1. Ensure you have defined default url options in your environments files. Here
     is an example of default_url_options appropriate for a development environment
     in config/environments/development.rb:

       config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

     In production, :host should be set to the actual host of your application.

     * Required for all applications. *
We are not going to need the configuration for the email, so we can skip that.
Let's go to the next one:
  2. Ensure you have defined root_url to *something* in your config/routes.rb.
     For example:

       root to: "home#index"
     
     * Not required for API-only Applications *
We don't have any controllers yet, we are going to add a new one to Devise later, so can skip this step for now as well.
Add flash messages:
  3. Ensure you have flash messages in app/views/layouts/application.html.erb.
     For example:

       <p class="notice"><%= notice %></p>
       <p class="alert"><%= alert %></p>

     * Not required for API-only Applications *
To add the flash messages, as indicated in the instruction, we need to open our
application.html.erb
file, but we don't want to always display them, so to show them just when they are being triggered we are going to wrap them inside conditional statements:
<% if notice %>
  <p class="alert alert-success"><%= notice %></p>
<% end %>
<% if alert %>
  <p class="alert alert-danger"><%= alert %></p>
<% end %>
Your
app/views/layouts/application.html.erb
should look like this:
<!DOCTYPE html>
<html>
  <head>
    <title>CustomAuthentication</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>
    <% if notice %>
      <p class="alert alert-success"><%= notice %></p>
    <% end %>
    <% if alert %>
      <p class="alert alert-danger"><%= alert %></p>
    <% end %>
    <%= yield %>
  </body>
</html>
And the last one:
  4. You can copy Devise views (for customization) to your app by running:

       rails g devise:views
       
     * Not required *
We are going to definitely need to do this to include our User profile image in our sign up form, so let's go ahead and run:
rails g devise:views
The next step is creating the model that is going to be used for our users and devise methods for the authentication. Devise is going to give us a hand with the generation of this model:
rails g devise User
This will create a model (if one does not exist) and configure it with the default Devise modules. The generator also configures your config/routes.rb file to point to the Devise controller.
There are additional configuration options, such as confirmable or lockable, some of them need an email service to work. We are going to keep this tutorial focused just on the customization of the user's registration with Active Storage. We can leave those additional configurations for another occasion.
Let's add an extra field to our migration file
db/migrate/xxxxxxxxxxxxxx_devise_create_users.rb
for the
username
, the migration file should look like this:
# frozen_string_literal: true

class DeviseCreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      ## Database authenticatable
      t.string :username,           null: false, default: ""
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      ## Recoverable
      t.string   :reset_password_token
      t.datetime :reset_password_sent_at

      ## Rememberable
      t.datetime :remember_created_at

      t.timestamps null: false
    end

    add_index :users, :email,                unique: true
    add_index :users, :reset_password_token, unique: true
  end
end
So, let's migrate our User model to the database:
rails db:migrate
Now, we need to associate the User model with the Active Storage by indicating how we are going to name their profile images. We are going to name it "avatar", to do this we need to add to our model the
has_one_attached
helper:
# app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  has_one_attached :avatar
end
Let's proceed with the addition of the field for the avatar in the User's registration view. It will be as simple as this:
  <div class="field">
    <div class="gravatar_edit">
      <%= f.file_field :avatar,
        placeholder: 'Upload picture',
        direct_upload: true
        %>
    </div>
  </div>
Notice the
direct_upload: true
argument necessary for the Active Storage to work.
Our final
app/views/devise/registrations/new.html.erb
will look like this:
<h2>Sign up</h2>

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

  <div class="field">
    <div class="gravatar_edit">
      <%= f.file_field :avatar,
        placeholder: 'Upload picture',
        id: "change-img-field",
        direct_upload: true
        %>
    </div>
  </div>

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

  <div class="field">
    <%= f.label :email %><br />
    <%= f.email_field :email, 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 "devise/shared/links" %>
To configure the acceptance of custom parameters in Devise, we need to explicitly indicate which ones we'll be allowed to pass in.
One way to do this, is stating it in the
/app/controllers/application_controller.rb
file:
# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_up, keys: [:username, :avatar])
  end
end
The syntax in Ruby is pretty straight forward, we are indicating that if it is one of the devise controllers, in this case for sign_up, we need to configure the permitted parameters by calling the protected method configure_permitted_parameters, we put it as protected because nothing outside to the class is going to use it. When we call the devise_parameter_sanitizer.permit method, we need to pass in the action, and the keys, which is an array of symbols with the names of the custom parameters.
Now, we need to go to our
config/storage.yml
file and set up our Cloudinary service, just adding this in the file:
cloudinary:
 service: Cloudinary
In our environment configuration files we need to indicate which service we are going to use for our Active Storage, let’s start with
config/environments/development.rb
. Locate the parameter for the active storage service and change it to
:cloudinary
:
Rails.application.configure do
  # ...

  config.active_storage.service = :cloudinary
end
You will need to prepare your
app/javascript/packs/application.js
file requiring the javascript of active storage included with its installation:
# app/javascript/packs/application.js

//= require activestorage
We are going to also need to add some javascript, you can go to the Active Storage Direct Uploads section, and grab its event listener examples. To add that javascript in the
application.js
file, it's going to have to require it self with the
require_self
helper method:
# app/javascript/packs/application.js

# ...

//= require_self

// direct_uploads.js
 
addEventListener("direct-upload:initialize", event => {
 const { target, detail } = event
 const { id, file } = detail
 target.insertAdjacentHTML("beforebegin", `
   <div id="direct-upload-${id}" class="direct-upload direct-upload--pending">
     <div id="direct-upload-progress-${id}" class="direct-upload__progress" style="width: 0%"></div>
     <span class="direct-upload__filename"></span>
   </div>
 `)
 target.previousElementSibling.querySelector(`.direct-upload__filename`).textContent = file.name
})
 
addEventListener("direct-upload:start", event => {
 const { id } = event.detail
 const element = document.getElementById(`direct-upload-${id}`)
 element.classList.remove("direct-upload--pending")
})
 
addEventListener("direct-upload:progress", event => {
 const { id, progress } = event.detail
 const progressElement = document.getElementById(`direct-upload-progress-${id}`)
 progressElement.style.width = `${progress}%`
})
 
addEventListener("direct-upload:error", event => {
 event.preventDefault()
 const { id, error } = event.detail
 const element = document.getElementById(`direct-upload-${id}`)
 element.classList.add("direct-upload--error")
 element.setAttribute("title", error)
})
 
addEventListener("direct-upload:end", event => {
 const { id } = event.detail
 const element = document.getElementById(`direct-upload-${id}`)
 element.classList.add("direct-upload--complete")
})
You can separate later this in another file and require it if you desire to have it more organized.
In Rails Guide there are also some example codes to style the progress bar of our direct upload and indicate if there was an error with the upload:
# app/assets/stylesheets/application.css

/* direct_uploads.css */

.direct-upload {
  display: inline-block;
  position: relative;
  padding: 2px 4px;
  margin: 0 3px 3px 0;
  border: 1px solid rgba(0, 0, 0, 0.3);
  border-radius: 3px;
  font-size: 11px;
  line-height: 13px;
}

.direct-upload--pending {
  opacity: 0.6;
}

.direct-upload__progress {
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  opacity: 0.2;
  background: #0076ff;
  transition: width 120ms ease-out, opacity 60ms 60ms ease-in;
  transform: translate3d(0, 0, 0);
}

.direct-upload--complete .direct-upload__progress {
  opacity: 0.4;
}

.direct-upload--error {
  border-color: red;
}

input[type=file][data-direct-upload-url][disabled] {
  display: none;
}
We can now test if the user authentication and the direct upload are working. First, run your rails server:
rails s
And open in your browser
localhost:3000/users/sign_up
, add an image, and complete the necessary fields to signup:
If everything is ok, you should be redirected to the welcome page of Rails. You can check now on you Cloudinary account if your image was uploaded in the Media Library section:
Now, let's see how to display those images in our App.
First, we need to create our Users controller, we are only going to need the show method:
rails g controller users show
I have to warn you that this show method is not going to be the classic one where you pass as a parameter the id of the user to display its data, this is only going to show the current user data to simplify our example.
In our routes, we need to add the devise_for helper to be able to use the current_user helper to display its data.
# /config/routes.rb

Rails.application.routes.draw do
  devise_for :users
  resources :users, only: %i[show]
end
And finally, we customize our
app/views/users/show.html.erb
file to display our username and avatar:
<h1>Hi <%= current_user.username %> </h1>

<%= image_tag(current_user.avatar, alt: "avatar", class: "avatar", id: "avatar") %>
Now, you're able to visit
localhost:3000/users/show
a welcome message with your user avatar:
If you have any kind of problems with the instructions, you can check out my Github repo including the necessary files and instructions to deploy the project.
I recommend you to check the official Devise documentation and the GoRails youtube channel that helped me to understand how to implement direct upload with Active Storage.
There are still a lot of things to add to this, but I hope that this tutorial had helped you in your journey to create a great authentication module!

Written by abdelp | Software Engineer with over 10 years of professional experience, working mostly with web technologies
Published by HackerNoon on 2020/11/28