In the first post, I walked through my journey of setting up a basic PERN (Postgres, Express, React, Node) todo application. One of the most common features of web applications is accounts and an authentication system. This post is about my journey adding these features to the original app. We'd like the ability to:
- Register a new account
- Log in to an existing account
- Associate each account to their own todo list
While this is a small list, it does entail a lot of very important concepts and packages: understanding authentication methods, JWTs, Passport JS, middleware, React router, React protected/private routes, React useContext, and database (Postgres) relationships.
Here are all the resources I used:
- Overview of authentication methods + JWT implementation on the BE with Passport JS (this was my main resource for JWT implementation)
- Introduction to JWTs and best practices
- Implementing Passport JS in a React app
- React private routes with JWT (this was my mean resource for private routes)
Use these links to help you get started on your own project and to follow along with the rest of this post. I've kept the full implementation for part 2 in a branch here so Part 1 can have its corresponding code intact. I suggest you open the repo in a separate tab or window as you read the post.
JWTs, Passport JS, and Register/Login routes
There are countless articles, StackOverflow posts, and blog posts about the "right way" to implement authentication. Some advocate using session-based and some JWT. With JWT, there is a debate about where to store the access token, how to implement refresh tokens, and more. All this made starting very difficult.
I opted for the easiest path for now as I'm just getting started with the implementation. I went with JWT based authentication with an access token, stored in Local Storage, and set an expiry time of 1 day. It is definitely not the most secure nor the best user experience, but I wanted to start somewhere.
In an upcoming post, I'll look into implementing refresh tokens (which I have done before while dabbling with the MERN stack) and using other means to store the access token. Let it slide for now and let's keep going.
I started by creating a "user" table in the database that stores first name, last name, email, and password (which we'll hash). See /server/database.sql.
CREATE TABLE "user"( user_id SERIAL PRIMARY KEY NOT NULL, firstname VARCHAR(255) NOT NULL, lastname VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, UNIQUE (email) );
I then added "/auth/login" and "/auth/register" routes that both generate a JWT (using the jsonwebtoken package) and sent it back in the response. On registration, I used bcrypt to hash the password before storing it in the database. See /server/routes/auth.routes.js and /server/lib/utils.
I used Passport JS as a middleware that ensures a valid JWT is provided. This protects some routes in our backend API from unauthorized users. I added the middleware to the /todos route and tested everything with Postman. All done on the server for now. We'll come back when we make the connection between users and todos. See /server/config/passport.js and /server/routes/todo.routes.js.
Frontend register/login flows and React useContext
With the backend API all set to allow registration and login, I set my sights on the frontend pieces related to these.
I created new pages for register and login. Within the corresponding submit methods for each page, I saved the JWT from the API response in Local Storage. See /client/src/components/Login.js and /client/src/components/Register.js.
To keep track of whether a user is logged in, I implemented useContext, which is a way to access a global prop in a React app without needing to pass props down multiple levels. Admittedly this wasn't necessary in my case because I didn't have a lot of nested components, but I wanted to learn. So I provide the context in App.js and pass the current user and a setUser method down to all components. See /client/src/App.js and /client/src/UserContext.js. Notice how we use setUser in Register.js and Login.js.
React private routes
Private routes are used on the client to ensure an unauthenticated user cannot access certain pages. In our case, the homepage with a user's todos.
With the help of the user context, I implemented a PrivateRoute component that I wrapped my homepage route with. At this point, I didn't implement the relationship between todos and users in the database. I also created public routes, which are routes that you can only get to if you're not authenticated. That's for the registration and login pages. If you weren't signed in and tried to access the homepage, you'd get redirected to the login page. If you were signed in and tried to access the login or registration pages, you'd get redirected to the homepage. See /client/src/PrivateRoute.js, /client/src/PublicRoute.js, and /client/src/App.js.
Next, I added a navbar to display the logged-in user's email and a logout button with its functionality. Logout is quite simple as we just have to clear everything in Local Storage and set the user context to null. Any action afterwards will automatically lead to being redirected to the login page. See /client/src/App.js.
In order to send the JWT from the frontend to the /todos API backend endpoints, I implemented an Axios request interceptor. It looks in Local Storage for a token and adds an "Authorization" header to the request before it's sent.
With this in place, we can now register, login, view/update/add/delete todos, stay logged in even if I leave the site or close the browser, and finally, I can log out. But every user sees all the same todos. I tackled this next.
Database updates and associated client/server changes
I linked the user and todo tables with a 1-to-many relationship so each user could have their own set of todos. I then updated the /todos routes to query the database using the specific user object returned from PassportJS (through the JWT).
CREATE TABLE "user"( user_id SERIAL PRIMARY KEY NOT NULL, firstname VARCHAR(255) NOT NULL, lastname VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, UNIQUE (email) ); CREATE TABLE todo( todo_id SERIAL PRIMARY KEY, user_id INT NOT NULL, description VARCHAR(255), CONSTRAINT FK_USER FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE );
I then changed the client to account for this relationship by only showing the logged-in user's todos. This was done with our existing userContext that contains the user's ID to pass to the server.
We're making some great progress but there are more things to learn:
- Authentication optimizations - safely store access token, implement refresh tokens
- Account management - ability to update my user account details
- Unit testing the client and server code
- Error handling and unhappy paths