cyishere

Auth with Express and React

2021-05-27

Accutally this is about the logic of "auth", because there're two parts in it: "authentication" and "authorization".

First of all, let me clear the difference between these two concepts.

Authentication: You are who you say you are.

Authorization: You can do what you want to do.

Authentication

When a user tries to log in, we need to find out who the user really is, this is "authentication". The logic of this is we get the information the user gives us, such as the user's name and password, we compare this info in our database to find out whether there's a user who has the same data. If there is, then the user is "authenticated".

After we verify the user is really who she is, we give her a encode string which is known as a "token". The token is her passport in our app.

That is the "authentication" flow. A graph shows blew.

The logic of authentication
The logic of authentication

Authentication Logic with Code

In the frontend, when the user clicks the "Login" button, the frontend sends a POST request to the backend.

// ./client/src/components/LoginForm.js

const loginSubmit = (e) => {
    e.preventDafault();

    return fetch("https://localhost:5000/api/user", {
        method: "POST"
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name, password }),
    })
    .then(response => response.json())
    .then(result => {
        if (result.type !== "error") {
            // login success
        } else {
            // show error messages
        }
    })
    .catch(err => {
        // show error messages
    });
};

When backend receives the login request, it checks the info in database, then returns error messages or authenticated token. (Here's an article about error handling logic.)

// ./server/routes/user.js

const router = require("express").Router();
const jwt = require("jsonwebtoken");
const { users } = require("../utils/data");
const SECRET = "thisSupposeToBeSecrectNotForPublic";

/**
 * @feature Login
 * @route POST /api/user
 * @access Public
 */
router.post("/", async (req, res, next) => {
  try {
    const { name, password } = req.body;

    // check if there are name and password
    if (name.trim() === "" || password.trim() === "") {
      const error = new Error("Name and password must not be empty.");
      error.statusCode = 400;
      throw error;
    }

    // check is there's a user in the database
    const user = users.find((u) => u.name === name);
    if (!user) {
      const error = new Error("No such user.");
      error.statusCode = 400;
      throw error;
    }

    // check whether the password is correct
    if (password !== user.password) {
      const error = new Error("Incorrect password...");
      error.statusCode = 400;
      throw error;
    }

    // if authenticated, generate a token
    const token = jwt.sign({ name }, SECRET);

    // return the token and user id
    res.json({
      userId: user.id,
      token,
    });
  } catch (error) {
    next(error);
  }
});

// profile: choose the right task, you'll see your profile

module.exports = router;

When the frontend gets the returned userId and token, it could keep them in the browser's cookie or local storage, but in this example, I put them in the state for further usage. Click here to see the whole code in the frontend login logic.

Now the user has the "token" passport, if she wants to access some information she needs to make the request with the token, so the backend server could check her identity. This kind of logic is "authorization".

Authorization

In my example, after the user logged in the screen shows her profile. The code for this behavior is sending an HTTP request along with the token in the HTTP header. Before we jump to the actual code, let's see a graph about the whole logic.

The logic of authorization
The logic of authorization

Authorization Logic with Code

  1. Send HTTP request in frontend
// ./client/src/components/Profile.js

// ...

const Profile = ({ userId, token }) => {
  const [user, setUser] = useState(null);
  const [message, setMessage] = useState(null);

  const getUserInfo = (tokenPassport) => {
    return fetch(`${api}/${userId}`, {
      method: "GET",
      headers: {
        "Content-Type": "application/json",
        Authorization: "Bearer " + tokenPassport, // send the token in request header
      },
    })
      .then((response) => response.json())
      .then((result) => {
        if (result.type !== "error") {
          setUser(result.user);
          setMessage(null);
        } else {
          setMessage(result.message);
        }
      })
      .catch((error) => {
        setMessage(error.message);
      });
  };

  useEffect(() => {
    if (!user) {
      getUserInfo(token);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [user, token]);

  // ...
};
  1. In the backend server, first to check whether the token is valid in a middleware

If it's an invalid user, she can't do what she cannot do.

// ./server/utils/middlewares.js
const jwt = require("jsonwebtoken");
const { SECRET } = require("./helpers");
const { users } = require("./data");

/**
 * Authorization
 */
// define a error helper
const unauthorizedError = (errorMsg) => {
  const error = new Error(errorMsg);
  error.statusCode = 403;
  throw error;
};

const auth = (req, res, next) => {
  try {
    // check whether there's authorization property in the request header
    const reqHeaderAuth = req.header("Authorization");
    if (!reqHeaderAuth) {
      unauthorizedError("No authorization...");
    }

    // get the token string,
    // if the format of the token is not right,
    // jsonwebtoken will throw an error
    // with "jwt malformed" message
    const token = reqHeaderAuth.split(" ")[1];

    // verify the token with our "secret"
    // if the token is invalid,
    // jsonwebtoken will throw an error,
    // such as an "invalid signature" message
    const decoded = jwt.verify(token, SECRET);

    // this `decoded` is an object contains one property we defined in the login method: { name }
    // check whether there's a user with this name in our database
    const user = users.find((u) => u.name === decoded.name);
    if (!user) {
      unauthorizedError("Invalid token...");
    }

    // when authorized, assign the user id to the `request`
    req.userId = user.id;

    next();
  } catch (error) {
    next(error);
  }
};

Now we know this is a valid user who is in our database, let's get some further information.

  1. Check whether the user is authorized in a specified endpoint
// ./server/routes/user.js

// ...
const { auth, unauthorizedError } = require("../utils/middlewares");

// ...

/**
 * @feature Get user's profile
 * @route GET /api/user/:id
 * @access Private
 */
// 🔴 NOTE: put the `auth` middleware here (router argument) to use it
router.get("/:id", auth, (req, res, next) => {
  try {
    // in the data.json, the user ids are number,
    // so convert string to number first
    const userId = Number(req.params.id);

    // we already check the user's identity in the auth middleware
    // now we need to check if the user can see this profile
    if (userId !== req.userId) {
      unauthorizedError("Unauthorized! You cannot see other's profile.");
    }

    // find the user by id in the database
    const user = users.find((u) => u.id === userId);
    delete user.password;

    // return the user info
    res.json({ user });
  } catch (error) {
    next(error);
  }
});

Please notice that we put the auth middleware in the router, because it's a middleware we can use it like this with Express.js. The difference between put it in a specified endpoint router method and in the main server.js file, such as app.use(auth), is the latter effects every endpoint route.

Thus, this is the logic of authorization. For more clear I add two behaviors in the example app, 1) see the team members. Every user could see the team members; 2) only Debbie Ocean could make a plan. The whole code of this example is in this repo .

  • ./client/src/components/Members.js makes the HTTP request to get all the members (getUsers);
  • The backend API is in the ./server/routes/user.js file.
  • makeAPlan request is in the ./client/src/components/Profile.js component;
  • The backend API is in the ./server/routes/plan.js file.

These two behaviors in action:

00:00
00:00
Picture In Picture
1
Error: Failed to load Video
video poster

When request is unauthorized.

00:00
00:00
Picture In Picture
1
Error: Failed to load Video
video poster

When request is authorized.