DEV Community

Cover image for In and Out of the Club: A Basic Login Application Using Rails Authentication
Chukwuma Anyadike
Chukwuma Anyadike

Posted on • Edited on

In and Out of the Club: A Basic Login Application Using Rails Authentication

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) =============================
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

ID check

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.

Login Page

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
Enter fullscreen mode Exit fullscreen mode

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).

LogInForm component

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
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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
Enter fullscreen mode Exit fullscreen mode

If there is a logged in user, successful login, or successful signup then you will see the welcome page which looks like this.

Welcome Page

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
Enter fullscreen mode Exit fullscreen mode

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)