Sessions
Now that we can create new accounts (signing up), we should enable "signing in". How does signing in work?
Every time the client sends a request, it comes with a token. If the token matches with the one we have in our database, that means the user is authorized.
In fact, whenever the browser sends a request, it will also send along the cookies. A cookie is a small piece of text stored on a user's computer by their browser. Each time the web browser interacts with a web server, it will pass the cookie information to the web server.
Cookies belong to its own domains only. So, cookies of example_1.com will not be sent to example_2.com.
We can set cookies to store information for use in later pages. So, what we usually do is:
- create a token (a code like
i75spJzfBMgyHovue4ZKnw
) - store this token in our database (server side)
- set this token inside a cookie in client's browser (client side)
- every time, the client makes a request to the server, it will automatically check if the token matches
So, how do we store tokens in the database. Well, we usually call them sessions. When you log in, the user has started a new session. When the user logs out, the session ends.
So, let's create a Session
table to track when user logs in and out.
$ rails g model session
In db/migrate/xxxx_create_sessions.rb
,
class CreateSessions < ActiveRecord::Migration[5.0]
def change
create_table :sessions do |t|
t.string :token
t.belongs_to :user, index: true, foreign_key: true
t.timestamps
end
end
end
We have the attribute token
, so we can compare this to the browser cookie. What's t.belongs_to :user
? Effectively, this Rails method will help you generate the user_id
attribute in the database, so you can associate each session with its user. index: true
is to index this attribute in this database. foreign_key: true
is to ensure referential integrity in the database level.
Why is Indexing important? https://stackoverflow.com/questions/1108/how-does-database-indexing-work What's Referential Integrity? https://en.wikipedia.org/wiki/Referential_integrity ActiveRecord and Referential Integrity http://guides.rubyonrails.org/active_record_migrations.html#active-record-and-referential-integrity
Every time a new session is created, we want it to automatically generate its token.
In app/models/session.rb
,
class Session < ApplicationRecord
belongs_to :user
before_validation :generate_session_token
private
def generate_session_token
self.token = SecureRandom.urlsafe_base64
end
end
SecureRandom.urlsafe_base64
generates a random URL-safe base64 string, meaning that it's a random string that's ok to be passed via the URL.
Some characters are not "safe" to passed via URL due to encoding issues: https://perishablepress.com/stop-using-unsafe-characters-in-urls/
We also need to make sure the two models are associated correctly:
In app/models/user.rb
,
class User < ApplicationRecord
has_many :sessions
end
Now that we have both the sessions and users model, let's create an API endpoint to log in a user (effectively, this is the same as "Log In" on the website).
In config/routes.rb
,
Rails.application.routes.draw do
# ...
# USERS
post '/users' => 'users#create'
# SESSIONS
post '/sessions' => 'sessions#create'
# ...
end
In app/controllers/sessions_controller.rb
,
class SessionsController < ApplicationController
def create
@user = User.find_by(username: params[:user][:username])
if @user and @user.password == params[:user][:password]
session = @user.sessions.create
cookies.permanent.signed[:todolist_session_token] = {
value: session.token,
httponly: true
}
render json: {
success: true
}
else
render json: {
success: false
}
end
end
end
Let's go over this line by line:
- The user sends a request with the
username
andpassword
to log in - With the
username
, we can find if the user exists withusername
being the identifier, hence@user = User.find_by(username: params[:user][:username])
if @user
checks if the user exists, if not, it will rendersuccess: false
- Otherwise, it will check if the login password matches with the one we have in our database, hence
@user.password == params[:user][:password]
- If the password matches, we will create a new session
session = @user.sessions.create
which will also automatically generate a new token stored inside. - We permanently set the cookie's key
todolist_session_token
to the value of the session tokensession.token
. - render
success: true
This will generate a token. Store that token in the database (server side). Store that token in the client's browser (client side). And, we say that the user has logged in.
The next time user makes another request, we can check the cookie and compare with our database. How about we make an endpoint that specifically check if the user is logged in or not?
We will use the endpoint /authenticated
.
In config/routes.rb
,
Rails.application.routes.draw do
# ...
# USERS
post '/users' => 'users#create'
# SESSIONS
# ...
get '/authenticated' => 'sessions#authenticated'
# ...
end
In app/controllers/sessions_controller.rb
,
class SessionsController < ApplicationController
def create
# ...
end
def authenticated
token = cookies.signed[:todolist_session_token]
session = Session.find_by(token: token)
if session
user = session.user
render json: {
authenticated: true,
username: user.username
}
else
render json: {
authenticated: false
}
end
end
end
cookies.signed[:todolist_session_token]
retrieves the cookie and the value of the key todolist_session_token
from the request. Once we get the value of the token, we try to find it in our database Session.find_by(token: token)
. If it's found, meaning that session
exists, then the user is authenticated, and vice versa.
Awesome! So, we just store the same code in two different places. We compare them every time the user makes a request. If they matches, then we know the user is indeed THE USER.
We can log in and check if we are logged in. How about logging out? Similarly, we can create another API endpoint to do that. In this case, the session in our database will be destroyed, so the tokens will no longer match. In fact, you can't find that session in our database anymore.
In config/routes.rb
,
Rails.application.routes.draw do
# ...
# USERS
post '/users' => 'users#create'
# SESSIONS
# ...
delete '/sessions' => 'sessions#destroy'
# ...
end
In app/controllers/sessions_controller.rb
,
class SessionsController < ApplicationController
def create
# ...
end
def authenticated
# ...
end
def destroy
token = cookies.signed[:todolist_session_token]
session = Session.find_by(token: token)
if session and session.destroy
render json: {
success: true
}
end
end
end
Similarly, we retrieve the cookie, get the token value, find the session that matches the token, and destroy that session. The user has now logged out, and the session has ended.
References: