Contents
- Introduction
- Authentication vs. Authorization
- Cookies and Sessions
- BCrypt
- Signing Up
- Logging In
- Auto Login
- Logging Out
- Authorizing
- Conclusion
- Resources
Introduction
As we expand our app development knowledge, let's dive into the world of authentication and authorization in Ruby on Rails. In this guide, we'll cover the essential concepts and tools you need to build secure web applications. Authentication and authorization are fundamental to ensuring the right users access the right resources. We'll explore topics like cookies and sessions, BCrypt for secure password handling, user sign-up and logIn processes, auto-login mechanisms, and user logout procedures with examples. Plus, we'll delve into the art of authorizing actions to secure the backend server from unwanted requests. Let's get started on this crucial aspect of web app development!
Authentication vs. Authorization
First we need to define the differences between authentication and authorization.
Authentication is the process of verifying the identity of the user. In other words, we are checking to make sure the users are who they say they are.
Authorization is the process of allowing certain users to gain access to certain features in the app.
Basically, authentication answers the question of “who are you?” while authorization determines “what are you allowed to do?” The security of any web application lies in its ability to authenticate and authorize users effectively, safeguard sensitive data, prevent unauthorized access, and continuously adapt to emerging threats.
Cookies and Sessions
While there are many ways to authenticate users, we will be exploring how to achieve this via cookies and sessions. Cookies and sessions are fundamental tools in web development for maintaining user state and enhancing security. They allow us to keep track of user data and interactions throughout their visit to our application.
Cookies are small pieces of data that a web server sends to a user’s browser (server to client). These data packages are domain-specific and stored on the user’s device after the user has visited a website or a web application so that on subsequent visits, the server quickly knows who the client is.
Cookies serve various purposes including storing information such as user authentication tokens, shopping cart contents, or user preferences. They are stored on the client-side in the browser and sent back to the server with each HTTP request, allowing the server to recognize and remember the user. In the context of authentication, cookies often store user session information to keep a user logged in across multiple interactions with a web application.
Sessions are a server-side mechanism for maintaining user state. Unlike cookies, which are stored on the user’s device, sessions are stored on the server.
When a user interacts with a web application, a unique session identifier is typically stored in a cookie on the user's device. This session identifier allows the server to associate subsequent requests from the same user with their session data stored on the server. In the context of authentication and authorization, user session data can include information like the user's ID, which are used to determine what actions the user is allowed to perform within the application.
Here is how this works:
- User visits a website/application and logs in.
- Server generates a session on the server-side and sends a cookie containing a session identifier to the client's browser.
- The client's browser stores this cookie.
- On subsequent requests, the client's browser automatically sends the stored cookie with the session identifier back to the server.
- The server uses this session identifier to retrieve the user's data from its session storage and identifies the user.
In order to enable cookies and sessions in our Rails application, there are some things we need to set up.
In our config/application.rb:
module MyApp
class Application < Rails::Application
# Add cookies and session middleware
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore
# Use SameSite=Strict for all cookies to help protect against CSRF
config.action_dispatch.cookies_same_site_protection = :strict
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 6.1
# Only loads a smaller set of middleware suitable for API only apps.
# Middleware like session, flash, cookies can be added back manually.
# Skip views, helpers and assets when generating a new resource.
config.api_only = true
end
end
In our app/controllers/application_controller.rb:
class ApplicationController < ActionController::API
include ActionController::Cookies
end
Once these files are properly set up, we can use cookies and sessions.
BCrypt
When a user first signs up, the server must store that information in their databases. However, one very important concept to keep in mind is that passwords are never saved as a plain text in any databases due to security risks. A standard method of storing passwords is through encryption via salting & hashing.
Salting is the process used to enhance the security of password storage. This process involves a random and unique piece of data called “salt”, which is generated and combined with every password before applying the hash function. This ensures that two users with the same password will have different values due to the unique salts.
Hashing is a one-way function that transforms the password into a fixed-length string of characters called “hash”. After a password has been salted, it undergoes the hashing process.
The primary characteristic of a good cryptographic hash function is that it's irreversible, meaning you can't reverse the process to retrieve the original password. Even a small change in the input data should produce a significantly different hash. In the context of password security, the user's password is salted and hashed and then stored in the database. When a user attempts to log in, their entered password is hashed again, and the resulting hash is compared to the stored hash in the database. If they match, the login attempt is successful.
Here is how it works:
- We have two users John and Jane with the same password, “Password123”.
- When they sign up, they are given two unique salts: John gets “sd9f6” and Jane gets “3x41p”.
- The salting process turns John’s password into “Password123sd9f6” and Jane’s into “Password1233x41p”.
- The hashing process via a secure hash function turns John’s salted password into “d3f4e5g6h7i8j9k0” and Jane’s into “m1n2o3p4q5r6s7t8”.
- The server stores John’s salt “sd9f6” and hashed password “d3f4e5g6h7i8j9k0” and Jane’s salt “3x41p” and hashed password “m1n2o3p4q5r6s7t8”.
- When John or Jane logs in, the salt and hash function converts their password input into the hashed version. If the hashed passwords match, they are successfully logged in.
To simplify this process as web developers, we can use a Ruby gem, BCrypt. BCrypt provides the has_secure_password
method for the User model. This method:
- Automatically salts and hashes user passwords
- Automatically adds a
password_confirmation
attribute to the model (used for signing up and updating passwords). - Automatically adds validations for password-related inputs, such as its presence and any other specified requirements (minimum length, required characters, etc)
- Provides the
authenticate
method for the Sessions controller.
All these features can be utilized after including the has_secure_password
method in the User model.
class User < ApplicationRecord
has_secure_password
end
One last thing we need to do before we move on is to add the password_digest
attribute as a string to our User model. This is where our salted and hashed passwords are stored. Once we include this attribute, BCrypt will automatically manage the passwords in a secure way.
Signing Up
To set up the sign-up logic in Rails, we first need to create the appropriate route in config/routes.rb.
Rails.application.routes.draw do
# signing up
post '/signup', to: 'users#create'
end
Since we are creating a new User instance, we need to have the route point to the users#create
controller action.
class UsersController < ApplicationController
# post '/signup'
def create
render json: User.create!(user_params), status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end
private
def user_params
params.require(:user).permit(:username, :password, :password_confirmation)
end
end
In our users#create
action, we:
- Create a new instance of the User model and save it to our database using the sister method
create!()
. This allows for Rails to catch any exceptions raised by ourrescue
clause. - Utilize strong parameters, which are defined under the private methods. We make sure to include both the
:password
and:password_confirmation
attributes in the permitted params. - Make sure to include the
status: :created
to let the browser know that the User was successfully created. - Include the
rescue ActiveRecord::RecordInvalid => e
to catch any invalid records. We render this error message in a hash and set thestatus: :unprocessable_entity
.
If we want to have the user automatically log in after creating a new account, we need to set up the login mechanism in our backend and create a new session after the frontend fetch post request.
Logging In
As with the sign up process, we first need to make a new route for this controller action.
Rails.application.routes.draw do
# signing up
post '/signup', to: 'users#create'
# logging in
post '/login', to: 'sessions#create'
end
This time, we point to our sessions controller. Remember, that when users log in, they must request for the server to create a new session that they can store server-side and refer to when they receive the cookie with the session identifier. For this reason, our controller action will be sessions#create
.
class SessionsController < ApplicationController
# post '/login'
def create
user = User.find_by(username: params[:username])
if user&.authenticate(params[:password])
session[:user_id] = user.id
render json: user, status: :created
else
render json: { error: 'Invalid username or password' }, status: :unauthorized
end
end
end
In our sessions#create
action, we:
- Use the
.find_by()
method on our User model to find the appropriate user by the username. - Set up a conditional using the
user&.authenticate(params[:password])
method. The&.
is a shorthand way of saying “if theuser
object exists, invoke theauthenticate(params[:password])
method on it. Otherwise, returnnil
.” - Create a new session hash with the key :user_id and value set to the actual user id (user.id) if the conditional is true. This hash typically represents a session token or identifier, which is essential for subsequent authenticated requests.
- Render a response with the status: :created if the conditional is true. This response usually contains the session-related information.
- Render an error message “Invalid username or password” with
status: :unauthorized
if the conditional is false.
Once this controller action is set up, we can make a fetch post request from our frontend whenever we need to create a session (log in). If we have our sign-up logic set up, it is typical for the frontend to automatically log in, so the frontend can utilize this fetch request at the end of the sign-up process as well.
Auto Login
When the user reloads a page or closes and reopens the browser, it typically starts a new browsing session. This means the session cookie from the previous session is no longer available, and the server can't associate the new request with the old session. Instead of having the user enter their credentials every time, this feature allows for the user to maintain their logged in status through page reloads.
Like before, we start by creating a new route for a new controller action.
Rails.application.routes.draw do
# signing up
post '/signup', to: 'users#create'
# logging in
post '/login', to: 'sessions#create'
# auto login
get '/me', to: 'users#show'
end
This time, we point to the users#show
action since we are looking for an existing user instance.
class UsersController < ApplicationController
# post '/signup'
def create
render json: User.create!(user_params), status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end
# get '/me'
def show
render json: User.find_by!(id: session[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Not authorized' }, status: :unauthorized
end
private
def user_params
params.require(:user).permit(:username, :password, :password_confirmation)
end
end
In our users#show
action, we:
- Use the
find_by!(id: session[:user_id])
method to find the existing user by its id. - Render the error message with
status: :unauthorized
if the user is not authenticated (if the session has expired or the user is not logged in).
With this set up, we make sure our frontend triggers a fetch get request to this route on start-up. On React, this can be done using useEffect(() => {}, [])
.
Once we are able to retrieve the user information from the backend, we can properly set up our frontend to save our user state. On React, this can be done using useContext()
to persist this data throughout our components.
Logging Out
Since we created a new session when logging in, we need to delete that session[:user_id] for logging out.
Rails.application.routes.draw do
# signing up
post '/signup', to: 'users#create'
# logging in
post '/login', to: 'sessions#create'
# auto login
get '/me', to: 'users#show'
# logging out
delete '/logout', to: 'sessions#destroy'
end
class SessionsController < ApplicationController
# post '/login'
def create
user = User.find_by(username: params[:username])
if user&.authenticate(params[:password])
session[:user_id] = user.id
render json: user, status: :created
else
render json: { error: 'Invalid username or password' }, status: :unauthorized
end
end
# delete '/logout'
def destroy
session.delete :user_id
head :no_content
end
end
In our sessions#destroy
action, we:
- Use the .delete(:user_id) method to delete the session[:user_id].
- Send an empty
head
with:no_content
.
Authorizing
To add another layer of backend routing security, we want to restrict access to most of our controller actions to those who are authorized. To restrict all controller actions, we must create a custom method in the application controller using before_action
.
class ApplicationController < ActionController::API
include ActionController::Cookies
before_action :authorize
def authorize
return render json: { error: "Not authorized" }, status: :unauthorized unless session.include? :user_id
end
end
This custom method authorize
sends an error message “Not authorized” whenever the user tries to access a controller action before logging in. However, there are some actions that must be exempt from this.
If we think about it, we need to allow the user to be able to sign up and log in without this method stopping them. So we need to create exemptions for these actions. Here is what our final users and sessions controllers should look like.
class UsersController < ApplicationController
skip_before_action :authorize, only: [:create]
# post '/signup'
def create
render json: User.create!(user_params), status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end
# get '/me'
def show
render json: User.find_by!(id: session[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Not authorized' }, status: :unauthorized
end
private
def user_params
params.require(:user).permit(:username, :password, :password_confirmation)
end
end
class SessionsController < ApplicationController
skip_before_action :authorize, only: [:create]
# post '/login'
def create
user = User.find_by(username: params[:username])
if user&.authenticate(params[:password])
session[:user_id] = user.id
render json: user, status: :created
else
render json: { error: 'Invalid username or password' }, status: :unauthorized
end
end
# delete '/logout'
def destroy
session.delete(:user_id)
head(:no_content)
end
end
The skip_before_action
allows us to exempt the sign up and log in actions from our custom authorize
method.
Conclusion
We’ve explored critical concepts of authentication and authorization in the context of web application security. We've established the importance of distinguishing between these two processes, ensuring that only authorized users can access protected resources while allowing for efficient authentication processes. Cookies and sessions were examined as tools for managing user sessions securely. Additionally, the use of BCrypt for password encryption was discussed, enhancing application security. By implementing these practices and understanding their roles, developers can build robust and secure web applications, safeguarding user data and privacy.
This guide is meant to serve as a learning tool for people who are just starting to learn authentication and authorization mechanisms in Rails. Please read the official documentation linked below for more information. Thank you for reading and if you have any questions or concerns about some of the material mentioned in this guide, please do not hesitate to contact me at jjpark987@gmail.com.
Resources
Rails Documentation on Sessions and Security
SameSite Documentation
BCrypt Documentation
Top comments (0)