31
loading...
This website collects cookies to deliver better user experience
gem
and run a few commands and there you have it. A working authentication system!method
s just to get the functionality of the app that I desired. So after reading a handful of articles on authentication, I decided to build my own.rails new custom-authentication -T -d postgresql
cd custom-authentication
-T
flag tells rails that we do not want the default test framework. We'll use RSpec
to test our application.gem "bcrypt", "~> 3.1.7"
group :development, :test do
gem "factory_bot_rails", "~> 6.2"
gem "rspec-rails", "~> 5.0", ">= 5.0.1"
gem "shoulda-matchers", "~> 4.5", ">= 4.5.1"
end
Gemfile
if you don't have it already. We'll not be discussing what these gems do. Try googling and I'll promise you that you'll learn more about those gems from their official documentation.rails g rspec:install
. After successfully running the command, you should have the spec
directory at the root of your application.support
inside the spec
directory.spec/support/factory_bot.rb
, add these lines.# spec/support/factory_bot.rb
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
end
spec/support/shoulda_matchers
, add these lines.# spec/support/shoulda_matchers.rb
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
end
method
s that the gems provide.spec/rails_helper.rb
and uncomment the lineDir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
User
model so that we can store the users credentials and later use them to authenticate the user.rails g model user email:string:uniq password:digest auth_token:string:uniq
db/migrate/234235235_create_users.rb
. Run rails db:migrate
after that.class CreateUsers < ActiveRecord::Migration[6.1]
def change
create_table :users do |t|
t.string :email
t.string :password_digest
t.string :auth_token
t.timestamps
end
add_index :users, :email, unique: true
add_index :users, :auth_token, unique: true
end
end
We'll use the auth_token
as a signed cookie.
spec/factories/users.rb
and let's set up the user
factory. In short, factories are a superset to the Rails fixtures.# spec/factories/users.rb
FactoryBot.define do
factory :user do
sequence(:email) { |n| "janethebest#{n}@example.com" }
password { "secretpassword" }
sequence(:auth_token) { |n| "secret_token#{n}" }
end
end
# spec/models/users_spec.rb
require "rails_helper"
RSpec.describe User, type: :model do
subject(:user) { build(:user) }
describe "validations" do
it { is_expected.to have_secure_password }
it { is_expected.to validate_presence_of(:email) }
it { is_expected.to validate_length_of(:email).is_at_most(255) }
it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
it { is_expected.to allow_value("[email protected]", "[email protected]").for(:email) }
it { is_expected.not_to allow_value("johndoeexample.com", "johjn@exa").for(:email) }
it { is_expected.to validate_length_of(:password).is_at_least(6) }
end
describe "callbacks" do
it "normalizes the email before validation" do
email = " [email protected] "
user.email = email.upcase
user.save!
expect(user.email).to eq("[email protected]")
end
it "generates user auth_token at random" do
user.auth_token = nil
user.save!
expect(user.auth_token).to be_present
end
end
end
app/models/user.rb
and paste in those linesclass User < ApplicationRecord
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
has_secure_password
has_secure_token :auth_token
before_validation :normalize_email
validates :email, presence: true, length: { maximum: 255 }, uniqueness: { case_sensitive: false }, format: { with: VALID_EMAIL_REGEX }
validates :password, presence: true, length: { minimum: 6 }, allow_blank: true
private
def normalize_email
self.email = email.to_s.strip.downcase
end
end
Note: If you notice that we are passing allow_blank: true
on the password validation. If you are worrying that the user signing up will be able to set in nil or an empty string as their password, then do not. bcrypt
will automatically throw an error when this happens.
The reason why we're doing that is to let the users not specify their password every single time they update their email address or their name.
user
model. Now let's move on to the users_controller
.rails g controller users
and navigate to the routes.rb
file.resources :users, only: %i[create]
specs
for the users_controller
.# spec/requests/users_spec.rb
require "rails_helper"
RSpec.describe "Users", type: :request do
let(:valid_attributes) { { email: "[email protected]", password: "secretpass" } }
let(:invalid_attributes) { { email: "[email protected]", password: "" } }
describe "#create" do
context "when the request is valid" do
it "creates the user" do
expect do
post users_path, params: { user: valid_attributes }
end.to change(User, :count).by(1)
end
it "stores the auth_token in the cookie" do
post users_path, params: { user: valid_attributes }
expect(signed_cookie[:auth_token]).to eq(User.first.auth_token) # You probably do not have the `signed_cookie` method
end
end
context "when the request is invalid" do
it "returns an error" do
post users_path, params: { user: invalid_attributes }
expect(json.dig(:errors, :password)).to be_present # You also do not have the `json` method. Let's add them first
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end
signed_cookie
and the json
method defined. Let's add them first.# spec/support/requests/sessions_helper.rb
module Requests
module SessionsHelper
def signed_cookie
ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash).signed
end
end
end
# spec/support/json_helper.rb
module JsonHelper
def json
JSON.parse(response.body, symbolize_names: true)
end
end
rails_helper.rb
file and include
these module
s# spec/rails_helper.rb
RSpec.configure do |config|
config.include JsonHelper
config.include Requests::SessionsHelper, type: :request
end
module
s, run the tests again and it should fail without complaining about the method not defined errors.app/controllers/users_controller.rb
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
user = User.new(user_params)
if user.save
cookies.signed.permanent[:auth_token] = user.auth_token # you could also set an expiring cookie that would expire after a certain time.
# do your thing. Redirect?
else
render json: { errors: user.errors }, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:email, :password)
end
end
user
record and verifying if the password entered is correct or not.authentication.rb
under the app/models/
directory.# app/models/authentication.rb
class Authentication
def initialize(params)
@email = params[:email].to_s.downcase
@password = params[:password]
end
def user
@user ||= User.find_by(email: @email)
return unless @user
@user.authenticate(@password) ? @user : nil
end
def authenticated?
user.present?
end
end
class
# spec/models/authentication_spec.rb
require "rails_helper"
RSpec.describe Authentication do
describe "#user" do
it "returns the user if present" do
user = create(:user)
auth = described_class.new(email: user.email, password: user.password)
expect(auth.user).to eq(user)
end
it "returns the user for case insensitive email" do
user = create(:user)
auth = described_class.new(email: user.email.upcase, password: user.password)
expect(auth.user).to eq(user)
end
it "returns nil if user is not found" do
auth = described_class.new(email: "[email protected]", password: "password")
expect(auth.user).to be_nil
end
it "returns nil if user's credentials do not match" do
user = create(:user)
auth = described_class.new(email: user.email, password: "invalidpassword")
expect(auth.user).to be_nil
end
end
describe "#authenticated?" do
it "returns true if user is found" do
user = create(:user)
auth = described_class.new(email: user.email, password: user.password)
expect(auth).to be_authenticated
end
it "returns false if user is not found" do
auth = described_class.new(email: "[email protected]", password: "password")
expect(auth).not_to be_authenticated
end
end
end
rails g controller sessions
and navigate to the routes.rb
file.resources :sessions, only: %i[create]
spec/requests/sessions_spec.rb
and let's write some specs.# spec/requests/sessions_spec.rb
require "rails_helper"
RSpec.describe "Sessions", type: :request do
describe "#create" do
context "when the request is valid" do
it "signs the user" do
user = create(:user)
post sessions_path, params: { email: user.email, password: user.password }
expect(signed_cookie[:auth_token]).to eq(user.auth_token)
end
end
context "when the request is invalid" do
it "does not sign the user" do
post sessions_path, params: { email: "[email protected]", password: "helloworld" }
expect(signed_cookie[:auth_token]).to be_nil
expect(json.dig(:errors, :invalid)).to be_present
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
auth = Authentication.new(params) # remember this class?
if auth.authenticated?
cookies.signed.permanent[:auth_token] = user.auth_token
# do your thing. Redirect to some page?
else
render json: { errors: { invalid: ["credentials"] } }, status: :unprocessable_entity
end
end
end
current_user
, authenticate_user
, etc.ActiveSupport::CurrentAttributes
class to make the authenticated user available globally. I know globals are controversial, but hey, why not for the sake of the tutorial.current.rb
within the app/models/
directory.# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user
end
application_controller.rb
file and add these lines.# app/controllers/application_controller.rb
before_action :set_current_user
private
def set_current_user
Current.user = User.find_by(auth_token: cookies.signed[:auth_token])
end
set_current_user
method will fire up and will try to set the Current.user
.set_current_user
fires up and sets the Current.user
for you to be used in your app.Current.user
fires up in each request, please do not use it within your background jobs.views
, I assure you that it's pretty simple. Try fiddling with the system and you can come up with even better solutions.