When I was younger I occasionally went to the club on the weekend after a hard week of studying. When I say club, I am talking about a regular club where people go to dance and mingle as well as imbibe certain beverages. I usually danced more than I mingled. When I went to the club, somebody usually checked my identification to verify my identity and either stamped my hand (usually with dye that did not come off for days) or placed a band on my wrist.
Now, what does going to a loud and sometimes smelly club have to do with logging in and authentication. As it turns out there are a lot of similarities (except the loud music and the smell). I will be taking you through the process of signing up a new user, logging in a new user, checking if a user is logged in, and logging out a user. This requires authentication which means checking that is user is who they say that they are. It is the same thing as checking your ID at the club. This process will be explained in excruciating detail. All the console commands and code will be listed upfront first. The logic will be illustrated by taking you through the signup, login, checking for logged in user, and logout. I will explain authentication along the way. Authorization comes after authentication and will not be discussed in any great detail. It means allowing the user to do certain things. It is like getting permission to drink from the bar.
Now we will start working on the backend (server) using Rails.
BACKEND (SERVER):
Generate models and migrations
rails g model User username password_digest --no-test-framework
invoke active_record
create db/migrate/20230402074035_create_users.rb
create app/models/user.rb
rails db:migrate
== 20230402074035 CreateUsers: migrating ======================================
-- create_table(:users)
-> 0.0039s
== 20230402074035 CreateUsers: migrated (0.0041s) =============================
Here are the migrations. We have created a users table with the columns :username
, and :password_digest
. I will discuss :password_digest
shortly. This is where the encrypted password is stored.
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :username
t.string :password_digest
t.timestamps
end
end
end
Here is the User
model. Note the has_secure_password
macro. The has_secure_password
macro provides two methods to the User model. These methods are :password
and :password_confirmation
. The users table must have a password_digest
column for these methods to work (allow a user to enter a new password). It uses a before_save
hook to compare password
and password_confirmation
, if there is a match then the user is saved and the hashed version of the password is stored in the password_digest
column. The has_secure_password
allows bcrypt to cryptographically hash and salt a password (encryption). Hashing a password is like putting some fruit in the blender and getting a smoothie. No matter how hard you try you cannot get the fruit back. Salting a password involves prepending a random 29-letter string to the hashed password. This macro also provides password validation with the method validates_confirmation_of
. In short, has_secure_password
allows password storage, encryption and validation.
class User < ApplicationRecord
has_secure_password
end
Generate controllers
We should start with creating the users controller. This controller contains two actions that allow a user to sign up with a username and password and check if a user is logged in by seeing if there is a user in session.
The users#create
action creates a user using the parameters passed from the front end. user_params
are strong params because only certain attributes are permitted (params.permit
) to prevent mass assignment vulnerability. The validity of the user is checked if the user is valid the the user instance is rendered as JSON, otherwise, you get some error messages. If the user is valid then the user is signed in that the same time (more on this below). This is when you are having your ID checked and getting a hand stamp or a wrist bracelet.
The users#show
action checks for the presence of a user. It finds a user using the id that corresponds to the user_id
in session. This will be explained more later, but when a user is logged in an attribute in the session hash (:user_id
) is set equal to the id of the user that is logged in (user.id
) using the session#create
method . If there is a user logged the user is rendered as JSON, otherwise an error message is rendered. This is where you can re-enter the club or not after leaving and coming back without have to have another ID check.
rails g controller users --no-test-framework
create app/controllers/users_controller.rb
class UsersController < ApplicationController
# POST /signup
def create
User.create(user_params)
if user.valid?
session[:user_id] = user.id
render json: user, status: :created
else
render json: {errors: user.errors.full_messages}, status: :unprocessable_entity
end
end
# GET /me
def show
user=User.find_by(id: session[:user_id])
if user
render json: user, status: :ok
else
render json: {error: "Not authorized"}, status: :unauthorized
end
end
private
def user_params
params.permit(:username, :password, :password_confirmation)
end
end
Here we create a sessions controller with a create
action for logging in that responds to a POST /login request, and a destroy
action for logging out that responds to a DELETE /logout request.
sessions#create
is responsible for logging in a user. The key step is session[user_id] = user.id
. However, there are a few things that need to happen first. This is where authentication comes into play. First, let me explain what a session hash is. A session has is a special type of cookie. Cookies are information that are sent from the server to the client in a cookies header. This information is stored in the browser. Subsequently every time the client makes a request to the server it sends these cookies to the server. The server can perform actions on these cookies or not. A session is an encrypted cookie (a serialized hash which is signed with a key). Regular cookies are stored as plain text in the browser can easily be seen by a user but session cannot since it is encrypted. Session comes in handy because user information can be stored.
In our create
method we search for a user using the username
sent in params. The second step is checking the password using the authenticate
method (user&.authenticate(params[:password])
). The &.
is known in Ruby as the "safe navigation operator". If user is nil, it will return nil; if not, it will call the authenticate
method on user. It would be similar to writing user && user.authenticate(params[:password])
. The authenticate
method takes our password, hashes and salts it, and compares it to the hash stored under password_digest
. If there is a match then a user_id attribute is created in session (session[user_id] = user.id
), set equal to the user.id and the user is subsequently signed (also done in the users#create method). If there is no match then an error message is returned. Incidentially, this is also done in the users#create
action as well. The user is logged in at the time of signup. This is actually being allowed to enter the club after passing the ID check and being marked.
Logging out is easy. sessions#destroy
action deletes the user_id attribute from sessions (session.delete :user_id
) hence logging out the user. One can also destroy the entire sessions hash by entering session.destroy
and that would do the job as well. This is leaving the club and having to come back and get another ID check.
rails g controller sessions --no-test-framework
create app/controllers/sessions_controller.rb
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: :unprocessable_entity
end
end
# DELETE /logout
def destroy
session.delete :user_id
head :no_content
end
end
Create custom routes
Let's not forget the routes. We need routes to communicate with our controllers. A route is an HTTP verb plus a path. Our routing logic (code) consist of our routes to a controller action. An action is a method in a controller. Here is the generic routing logic. This is important because we need to create custom routes.
HTTP verb '/path', to: controller#action
Here are our custom routes. Note that each route communicates with a controller to perform a specific action.
Rails.application.routes.draw do
post '/login', to: 'sessions#create'
delete 'logout', to: 'sessions#destroy'
post '/signup', to: 'users#create'
get '/me', to: 'users#show'
end
FRONTEND (CLIENT):
I have tested the backend code using Postman, and so far it works. Now, we will work on the frontend (client) using React. Here, I will start talking about the request-response cycle for checking for a logged in user, logging in, signing up a new user, and logging out.
CHECKING FOR A LOGGED IN USER
Top level App
component. Checks to see if there is a user signed in using the /me
route. The URL domain is proxied via "proxy": "http://localhost:3000"
in our package.json folder.
Here, as soon as App
is rendered a fetch request is sent to /me
using the HTTP verb GET
. This combination of the HTTP verb GET
and the path /me
sends a message to the users controller to execute the show method (get '/me', to: 'users#show'
) in the User model to see if there is a logged in user. If there is a user then a JSON response is sent with the user information back to the front end. The frontend saves the user in state (setUser(user)
). The user
becomes a truthy value and the welcome page of application is displayed.
import React from 'react'
import {useState, useEffect} from 'react'
import Login from './Login'
import LogOut from './LogOut'
function App() {
const [user, setUser] = useState(null)
const welcomeToTheClub = "https://media.tenor.com/EOQex3fN9-EAAAAC/50cent-club.gif"
useEffect(() => {
fetch('/me')
.then(res=>{
if (res.ok) {
res.json().then(user=>setUser(user))
}}
)
}, [])
if (!user) return <Login setLogin={setUser}/>
return (
<div className='welcome-page'>
<h1>
Welcome to the Club
</h1>
<img src={welcomeToTheClub} alt="50 Cent in the club"/>
<LogOut setLogin={setUser} />
</div>
);
}
export default App;
If there is no user then the user
remains a falsy value and the Login
page component is displayed. The Login
Page which uses the LogInForm
and SignUpForm
components which will be described further.
import React from 'react'
import LogInForm from './LogInForm'
import SignUpForm from './SignUpForm'
import { useState } from 'react'
function Login({setLogin}) {
const [wantToSignUp, setWantToSignUp] = useState(false)
return (
<div className='form'>
<LogInForm setLogin={setLogin}/>
<p>If you do not have an account then click the button below to sign up.</p>
<button onClick={()=>setWantToSignUp(!wantToSignUp)}>
{!wantToSignUp? "Sign Up" : "Close sign up form" }
</button>
{wantToSignUp ? <SignUpForm setLogin={setLogin}/> : null}
</div>
)
}
export default Login
LOGGING IN A USER
Here is our LogInForm
component. Many times there will not be a user logged in and the user will have to log in. Assume there is a pre-existing user. This user will log in with a username and password. The form is submitted. A fetch is done and a POST request is made to /login
with the username and password being sent in a params object (in JS/React it is an object, in Ruby it is a hash).
This combination of the HTTP verb POST
and the path /login
sends a message to the sessions controller to execute the create method (post '/login', to: 'sessions#create'
). The username and password are authenticated as previously described. If authentication is successful then a JSON response is sent with the user information back to the front end. The frontend saves the user in state (setLogin(user)
). The user
becomes a truthy value and the welcome page of application is displayed. If there is no user then the user
remains a falsy value (null) and error messages are generated on the Login
page in the LogInForm
component.
import React from 'react'
import {useState} from 'react'
function LogInForm({setLogin}) {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [errors, setErrors] = useState(null)
function handleSubmit(event) {
event.preventDefault()
fetch("/login", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({username, password})
}).then(response => {
if (response.ok){
response.json().then(user=>setLogin(user))
} else {
response.json().then(errors=>setErrors(errors))
}
})
}
return (
<div>
<h2>Welcome, enter information below to login</h2>
<form onSubmit={handleSubmit}>
<label>Enter a username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e)=>setUsername(e.target.value)}
/>
<br/>
<label>Enter a password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e)=>setPassword(e.target.value)}
/>
<br/>
<button type="submit">Enter</button>
</form>
{errors? <p className='error'>{errors.error}</p> : null}
</div>
)
}
export default LogInForm
SIGN UP A NEW USER
Here is our SignUpForm
component. This form takes in a username, password, and password_confirmation. A fetch is done and a POST request is made to /signup
with the username
, password
, and password_confirmation
being sent in a params object.
This combination of the HTTP verb POST
and the path /signup
sends a message to the users controller to execute the create method (post '/signup', to: 'users#create'
). A user is created with the params passed from the from end. If the user is valid a JSON response is sent with the user information back to the front end. In this case the user is also logged in at the time of signup by creating a :user_id
attribute in the session hash and setting that value equal to user.id
. The frontend saves the user in state (setLogin(user)
). The user
becomes a truthy value and the welcome page of application is displayed. If there is no user then the user
remains a falsy value (null) and error messages are generated on the Login
page in the SignUpForm
component.
import React from 'react'
import {useState} from 'react'
function SignUpForm({setLogin}) {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [passwordConfirmation, setPasswordConfirmation] = useState("")
const [errors, setErrors] = useState(null)
function handleSubmit(event) {
event.preventDefault()
fetch("/signup", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
username,
password,
password_confirmation: passwordConfirmation
})
}).then(response => {
if (response.ok){
response.json().then(user=>setLogin(user))
} else {
response.json().then(errors=>setErrors(errors))
}
})
}
return (
<div>
<form onSubmit={handleSubmit}>
<label>Enter a username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e)=>setUsername(e.target.value)}
/>
<br/>
<label>Enter a password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e)=>setPassword(e.target.value)}
/>
<br/>
<label>Confirm password:</label>
<input
type="password"
id="password_confirmation"
value={passwordConfirmation}
onChange={(e)=>setPasswordConfirmation(e.target.value)}
/>
<button type="submit">Enter</button>
</form>
{errors? <p className='error' >{errors.errors}</p> : null}
</div>
)
}
export default SignUpForm
If there is a logged in user, successful login, or successful signup then you will see the welcome page which looks like this.
You have now entered the club. Note the log out button at the bottom which leads to our next topic.
LOG OUT A USER
Here is the LogOut
Component. A fetch is done and a DELETE request is made to /logout
.
This combination of the HTTP verb DELETE
and the path /logout
sends a message to the sessions controller to execute the destroy method (delete '/logout', to: 'sessions#destroy'
). The user_id
attribute of the sessions hash is destroyed and no JSON is sent. This signs out the user on the backend. The user
is set to null in the frontend and once again becomes a falsy value and the user is logged out and the login page of application is displayed.
import React from 'react'
function LogOut({setLogin}) {
function signOut() {
fetch('/logout', {
method: 'DELETE'
})
setLogin(false)
}
return (
<div>
<p>Click the button below to logout</p>
<button onClick={()=>signOut()}>Log Out</button>
</div>
)
}
export default LogOut
This is how I have constructed a basic log in application. Make sure that your application is configured to use cookies and that the bcrypt
gem is installed.
In summary:
For each of these actions you need a custom route (appropriate HTTP verb and path) which maps to a controller action in the backend. On the front end the appropriate fetch request to that route must be made.
- To log in: post '/login', to: 'sessions#create'
- To log out: delete 'logout', to: 'sessions#destroy'
- To signup a new user: post '/signup', to: 'users#create'
- To check for a signed in user: get '/me', to: 'users#show'
I hope that you have gained a better understanding of how to use Rails/React to sign up users, log in users, check if a user is logged in, and log out a user. This is also a good way to illustrate the request-response cycle and talk about authentication. Thank you for you patience. I'm leaving the club. Peace out.
Top comments (0)