Wednesday, February 2, 2022

Authentication React

In the previous post we looked at how to setup authentication in a Rails backend. In this post we'll be looking at how all of this works on the client side and be backend agnostic.

  1. We're going to make an API request to our backend passing the token through in the headers, the backend will then verify the validity of that token and then send back the JSON response if everything checks out, we're using a /status endpoint for this

  2. Add this to a RequireAuth.tsx file:

import { useState, useEffect } from "react";
import { Navigate, useLocation } from "react-router-dom";

export function RequireAuth({ children }: { children: JSX.Element }) {
  const [auth, setAuth] = useState(false);
  const [loading, setLoading] = useState(true);
  let location = useLocation();

  useEffect(() => {
    async function checkAuthStatus() {
      try {
        const response = await fetch("http://localhost:3000/status", {
          headers: {
            Authorization: `Bearer ${localStorage.getItem("token")}`,
            "Content-Type": "application/json",
          },
        });
        if (response.status >= 400) {
          throw new Error("Not authorized");
        } else {
          const data = await response.json();
          localStorage.setItem("token", data.token);
          setAuth(true);
          setLoading(false);
        }
      } catch (err) {
        console.log(err);
        setLoading(false);
      }
    }
    checkAuthStatus();
  }, []);

  if (loading) {
    return null;
  }

  if (!loading && !auth) {
    return <Navigate to="/login" state={{ from: location }} />;
  }

  return children;
}
  1. Add this to <Navbar>
<Link to="/secrets">Secrets</Link>
  1. Click on the link or try typing in this url http://localhost:8080/secrets

  2. In the console you'll be getting two error logs, why are we getting these logs, how are we redirecting back to the /login path?

  3. Open up Postman, login as one of your valid users and copy the JWT you get back in the HTTP response

  4. Go back to your browser running localhost, open your developer tools and navigate to the application tab

  5. Paste a key value pair into local storage

token | <jwt you just copied>
  1. Try clicking on the secrets link again, we should be able to access /secrets

  2. Check your local storage token key/value and notice that the jwt changes every time we refresh the page, we're now implementing something called sliding sessions

  3. We can't rely on our users going into dev tools and adding a jwt to local storage themselves, we need this to happen dynamically! Remove the key/value pair in local storage and let's make two new components <Login /> and <SignUp />

  4. Add this to <Navbar />

<Link to="/login">Login</Link>
<Link to="/sign-up">Sign Up</Link>
  1. Add this to <App />
<Route path="/login" element={<Login />} />
  1. Let's focus on <Login /> first, here's the component:
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { LoggedOutNav } from "./Nav";

export function Login() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [errMessage, setErrMessage] = useState("");
  const navigate = useNavigate();

  async function onFormSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    try {
      const response = await fetch("http://localhost:3000/login", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          email,
          password,
        }),
      });
      if (response.status >= 400) {
        throw new Error("Incorrect credentials");
      } else {
        const { token } = await response.json();
        localStorage.setItem("token", token);
        navigate("/secrets");
      }
    } catch (err) {
      console.log(err);
      setErrMessage("Server error!");
    }
  }

  return (
    <>
      <LoggedOutNav />
      <h1>Login</h1>
      {errMessage && <span>{errMessage}</span>}
      <form onSubmit={onFormSubmit}>
        <label htmlFor="email">Email</label>
        <input
          type="email"
          name="email"
          id="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
        <label htmlFor="password">Password</label>
        <input
          type="password"
          name="password"
          id="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
        <input type="submit" value="Submit" />
      </form>
    </>
  );
}
  1. We ensure that it's a controlled component through the onChange on our inputs, we set the values in state, when the form is submitted we make a POST request to the backend getting the jwt and then put it into local storage before redirecting to the /secrets view

  2. Add this to <App />

<Route path="/sign-up" element={<SignUp />} />
  1. Let's focus on <SignUp /> below is the implementation
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { LoggedOutNav } from "./Nav";

export function SignUp() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const navigate = useNavigate();

  async function onFormSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    try {
      const response = await fetch("http://localhost:3000/sign-up", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ email, password }),
      });
      if (response.status >= 400) {
        throw new Error("Incorrect credentials");
      } else {
        const { token } = await response.json();
        localStorage.setItem("token", token);
        navigate("/secrets");
      }
    } catch (err) {
      console.log(err);
    }
  }

  return (
    <>
      <LoggedOutNav />
      <h1>Sign Up</h1>
      <form onSubmit={onFormSubmit}>
        <label htmlFor="email">Email</label>
        <input
          type="email"
          name="email"
          id="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
        <label htmlFor="password">Password</label>
        <input
          type="password"
          name="password"
          id="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
        <input type="submit" value="Submit" />
      </form>
    </>
  );
}
  1. Our private routes are wrapped in a call to <RequireAuth />, our public routes don't need to be wrapped
import { Routes, Route } from "react-router-dom";
import { Home } from "./Home";
import { Login } from "./Login";
import { RequireAuth } from "./RequireAuth";
import { Secrets } from "./Secrets";
import { SignUp } from "./SignUp";

function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route
        path="/secrets"
        element={
          <RequireAuth>
            <Secrets />
          </RequireAuth>
        }
      />
      <Route path="/login" element={<Login />} />
      <Route path="/sign-up" element={<SignUp />} />
    </Routes>
  );
}

export default App;
  1. Last but not least we need a way to logout! This can all be handled within the <Navbar />
import React from "react";
import { Link, useNavigate } from "react-router-dom";

export function LoggedInNav() {
  const navigate = useNavigate();

  function logout(event: React.SyntheticEvent) {
    event.preventDefault();
    localStorage.removeItem("token");
    navigate("/login");
  }

  return (
    <nav>
      <p>You are logged in!</p>
      <Link to="/" onClick={logout}>Sign out</Link>
    </nav>
  );
}

export function LoggedOutNav() {
  return (
    <nav>
      <Link to="/login">Login</Link>
      <Link to="/sign-up">Sign Up</Link>
    </nav>
  );
}