As I was finishing up my fourth phase at Flatiron School
we learned about how to make a login/signup feature on web applications using an authentication process. This phase in my software development program is centered around the fullstack framework Ruby on Rails. It's quite a power language that can do both frontend and backend programming.
The process to make a website secured through an login or signup authentication process can be very complicated. I definitely had some trouble getting this process down, so I decided the best way to climb this mountain was to write a blog about it to fully conquer authentication.
If you are familiar with Ruby on Rails, you probably are know about Gemfiles. They are project dependencies, similar to node modules, that have pre-made code that does a lot of heavy lifting for you, so you can focus on other aspects of your project. We will be using BCrypt.
BCrypt is a hefty gemfile that uses hashes and salts to encrypt passwords in your database. Hashes are a fixed-length value that will always produce the same output for the same given input. The output can be a 32-bit or 64-bit number. What BCrypt does, is add a salt to this hash, which is an additional 29-bit number that generates a new salt every time you login with your password. This method makes it incredibly difficult to hack into the database to reveal the actual password to a user.
Additionally, this gem adds three methods that the user model can use to read, write, and encrypt passwords:
password=
password_confirmation=
authenticate
As there are many ways to authenticate,
this is the method I have found to work (after a lot of troubleshooting). There is a lot to set up, but there is flexibility in how you implement your ruby methods and display your data in the front end. For this example, I am using Ruby v.2.7.4, Rails v.6.1.3 along with React v.17.0.2 and React Dom v.5 for my frontend framework.
Setup Work Flow
- Install gemfiles
If you are starting a new rails project, these are some important gems to include in your Gemfile:
gem 'bcrypt', '~> 3.1.7'
gem 'rack-cors'
gem 'pg', '~> 1.1'
Something of note is that I use PostgreSQL for my database, and rack-cors as middleware. Rack-cors is usually needed when you are deal with separate frontend and backend projects that work through different domains. Theoretically if you are working on a mono-template, where both ends are in the same parent directory, you won't need rack-cors. When working through this demo, if you encounter any issues related to AJAX or CORS, I would recommend installing rack-cors in your gemfile.
bundle install
to install all of your gem files. Additionally, don't forget to npm install
all your node modules for your frontend (Use this command in your React folder if your local environment combines both frontend and backend in a parent directory).
Next,
we will need to configure Rails to create cookies and sessions (I highly recommend learning about sessions and how Rails interacts with, more info through the Rails Docs). Inside the configure/application.rb
file include the following:
module MyApp
class Application < Rails::Application
config.load_defaults 6.1
config.api_only = true
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore
config.action_dispatch.cookies_same_site_protection = :strict
end
end
The last line that starts with configu.action_dispatch
is important to include so that cookies are only shared in the same domain.
Moving on,
we need to add a line of code to our app/controllers/application_controller.rb
file:
class ApplicationController < ActionController::API
include ActionController::Cookies
end
Create
a user model. I prefer using a resource generator to fast-track some of the coding. In your terminal in your project directory, use this command:
rails g resource User name email password_digest
This will create a user controller, model, serializer, and all CRUD routes in your config/routes.rb
file. The column in your user table can be whatever you would like, but the password_digest column is vital to interact with bcrypt.
Add
this to the User Model file:
has_secure_password
This allows up to have proper validations for the password.
Whew, that was a lot to set up. Let's start writing some methods.
Sign Up Work Flow
- Frontend display a signup form.
- Client submits form, initiating a
POST
request to a create method. - The backend will make sure the data is properly validated. If it is, it will serialize a newly created object (a user row in the User table). If invalid, the server is provide error messages.
Before we talk about the frontend, let's deal with what the server side will do.
Although not necessary, it is advised to create a custom route for your users#create
method, like this:
post '/signup', to: 'users#create'
Using a custom route will allow to continue to use the create
method for the user controller if you ever need to.
In your User Controller
create your strong params, to control what the client can send to the database. In a private method, write:
def user_params
params.permit(:username, :email, :password, :password_confirmation)
end
Then write your create method:
def create
user = User.create(user_params)
if user.valid?
session[:user_id] = user.id # this is the piece that logs a user in and keeps track of users info in subsequent requests.
render json: user, status: :ok
else
render json: user.errors.full_messages, status: :unprocessable_entity
end
end
Let's breakdown this method:
- We first declare a variable, that creates and saves a new User object with the user_params.
- If the user takes in the valid parameters, it will create a new session, using the id of the newly create user variable. This session will allow the client to stay logged in throughout their entire experience using the web application.
- If the user is NOT valid, it will render a json object of error messages that can subsequently be rendered by the frontend to display.
On the Frontend
this is how I used my frontend to control a form, use State
to create a new object with the form data, and to make a fetch request on submit:
import React, { useState } from "react";
function Signup() {
const [currentUser, setCurrentUser] = useState({})
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
});
const { name, email, password } = formData;
function handleChange(e) {
const { name, value } = e.target;
setFormData({ ...formData, [name]: value });
}
function handleSubmit(e) {
e.preventDefault();
fetch(`/signup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
}).then((res) => {
if (res.ok) {
res.json().then((formData) => {
setCurrentUser(formData);
});
} else {
res.json().then((errors) => {
console.error(errors);
});
}
});
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">Username:</label>
<input
id="username-signup-input"
type="text"
name="name"
value={formData.name}
onChange={handleChange}
/>
<label htmlFor="email">Email:</label>
<input
id="email-signup-input"
type="text"
name="email"
value={formData.email}
onChange={handleChange}
/>
<label htmlFor="password">Password:</label>
<input
id="password-signup-input"
type="password"
name="password"
value={formData.password}
onChange={handleChange}
/>
<button type="submit">Submit</button>
</form>
);
}
export default Signup;
Important Note:
When I used the fetch in React, I had issues actually creating a session. What fixed this issue was using back-ticks for the URL in your fetch. I would recommend using these back-ticks as precaution.
One additional note about the front end is in the onSubmit function: after the POST
request, this function uses a callback function called setCurrentUser
that will set state to the newly created user object. That way you can use a useEffect
hook to call this state upon initial render of the application. That way your front end will know if the current user is logged in when navigating through the webpage.
To Display User Information
We need to verify that the current user id matches the session id.
In your Application Controller, write a method to check if these two ids match:
def current_user
User.find_by(id: session[:user_id])
end
Great, now let's focus back on the User. Create a new custom route:
get '/me', to: "users#show"
Inside your User model, add the appropriate show
method:
def show
if current_user
render json: current_user, status: :ok
else
render json: "No current session stored", status: :unauthorized
end
end
Because the current_user
method in the Application Controller can be inherited in all children controllers, this method is using the current_user method to create a boolean return of either true or false. If true, then render a json object of the current_user's data.
Log In/Log Out Workflow
This is a similar workflow to the Sign Up process:
- Write custom routes for the
create
anddestroy
for your Sessions Controller - Write the appropriate methods in your Sessions Controller.
- Write the frontend code that practically mimics the Signup component.
If you don't have sessions controller already, use this command in your terminal:
rails g controller Sessions
Logging In...
In your config/routes.rb file:
post "/login", to: "sessions#create"
In your Sessions Controller, write a create
method that would create a user variable that uses :params
from the form in the frontend to find the appropriate user in the database. If the credentials match up, render a json object of the user variable. If they don't match up, render error messages:
def create
user = User.find_by(username: params[:username])
if user&.authenticate(params[:password])
session[:user_id] = user.id
render json: user, status: :ok
else
render json: "Invalid Credentials", status: :unauthorized
end
end
Then write your front end to take in data from a controlled form and use a POST
request to the /login endpoint. This code should look almost identical to the signup component.
Logging Out...
In your config/routes.rb file:
post "/logout", to: "sessions#destroy"
In your Sessions Controller, write a destroy
method that will delete the current session:
def destroy
session.delete :user_id
end
Your front end will fetch a DELETE
request to the /logout
endpoint.
AND THAT'S A WRAP. Take a couple deep breaths, it's as complicated as you think.
Final Notes
- Go through this process slowly. Make you sure you understand every part of the code before moving on.
- Think about work flow process for each feature. Go step by step, from the client side to the server side, to write your code.
- If you get errors, use Google. Errors are your friend, you WILL debug your way through it.
Hopefully this blog has provided a little bit of clarity to authenticating. Thanks for reading. 'Til next time!
Top comments (0)