Here is how we can do Auth for our projects.
- Auth Template
- Authentication vs Authorization
- Advice
- Explaining Trade offs
- Client System
- What kind of apps can we build with a Client like this?
- Understanding The Code
- Fork this template repo
- Copy the
.env.template
and name it.env
- Create an
auth_example
database (or update your new.env
to whatever database you are using) - Double check that the
.env
variables are all correct (username, password, database name) npm run kickstart
(npm run dev
ornpm start
afterwards)
Remember, authenticated
means "We have confirmed this person is who they say they are" and authorized
means "This person is who they say they are AND they are allowed to be here." So if we just want a user to be logged into the site to show content, we just check if they're authenticated
. However, if they wanted to update their profile info, we'd need to make sure they were authorized
to do that (e.g. the profile they're updating is their own).
What's super annoying is if a user has missing or malformed credentials (they are not authenticated)...the 401 error we throw says "unauthorized." And when a user is authenticated but not authorized, the 403 you throw says "Forbidden." Sometimes the internet is just weird.
Remember, DO NOT TRUST THE FRONTEND. Validate everything on the server. Just because you block a form in the GUI doesn't mean a nefarious actor couldn't just pop open a console and make a fetch
request. Also, the frontend can be buggy and mistakes can happen.
Nothing in life is free, so here are some of the pros/cons of this template's approach.
Since our entire application lives on one server (our frontend is just a bunch of static pages), that means we can use cookies and server sessions for auth. JWTs are better for situations where you don't control everything or have multiple servers that need to maintain users across them. Also, JWTS should be stored in the cookie anyway, so we might as well just use the much smaller sessions.
You may also see tutorials that use JWTs saved in localStorage
, but that's super insecure and is getting increasingly frowned upon. Sessions also have security issues we aren't dealing with, but nowhere near as blatant.
While more limited in size (4kb is the absolute max amount of info), cookie sessions are much easier to understand.
- When a request comes in for signup/login, the server creates a cookie (the
handle-cookie-sessions
middleware does this for us). - The model will store the user data in the database (or look it up for
/login
) and return back the user with it's uniqueuser.id
- When we get the
User
back from the model, we store theuser.id
in that cookie. - Now, that cookie lives with every request made by that user (
req.session
) and the client can check if it is logged in using the/api/me
endpoint (see below).
Unlike traditional sessions, there is no external store, the session data is the cookie. To log out, just remove the cookie via setting it to null
.
In this example the cookie's lifespan isn't specified, which means it defaults to Session
. A length of Session
means that as long as the user's browser stays open (That's the browser, not the tab) the cookie will stick around. For now this is what we want, because we don't want to worry too much about re-auth flow at some arbitrary time in the middle of the user doing something.
That being said, we do have 2 examples of how we can handle if we unexpectedly fail auth: a "secret message" route, and a username update route. For more information see the client section.
You should only try this approach if you fully understand cookie sessions.
Here's a nice "getting started" article on Express sessions. The big hurdle with sessions is that they need to be stored somewhere. Usually that's a global store like Redis (which is also crazy fast). But to keep things simple, we're just using our database, and hooking it up with knex.
The tl;dr on true sessions is this: when a user logs in, we create a session cookie and a session DB entry. All that cookie has on it is the session id. Then, on the server, we always have access to the same session info from the DB, so we can load and read anything from it. Generally, that's just the logged in user data. However, the only thing that ever gets sent in the cookie is the session id, which means we can load other things into our session without size concerns. With true sesssions, the session is saved on the DB, the session ID is the cookie.
To logout a user, simply call .destroy()
on the session. The cookie still exists, but since there is no matching session, it just sort of chills there until it dies.
What's super cool about these two types is that it's extremely plug and play for us. So while we're using handle-cookie-sessions.js
middleware, I've included handle-sessions.js
middleware as well. It has all the DB directions copied in. Other than log outs, the application logic is exactly the same! You don't have to use it, but it's fun to experiment with once you fully understand cookie sessions.
Without a proper front end router or backend template system, we're a little limited. So for this setup, let's try to keep the number of html files small (in fact, the signup and login pages could probably be combined). We also lack a proper bundler like Vite or an import system like ESModules, so we have to keep a globals
file for universal functions and values. Make sure globals is always the first script loaded in the head tag of the page.
In order to keep source of truth simple, we're going to track who is logged in with that GET /api/me
convention. Each time a page is loaded, we quickly hit GET /api/me
. If there is a logged in user, we'll see that in the json. Saving the user info into another global store like localStorage has network advantages, but also some rather harsh drawbacks. Given that it has a different lifespan than our cookies (and can also be modified with client side JS), this was such a shaky source of truth, we ultimately reconsidered using that technique. Also, those network advantages go away once we have a proper front end router and we aren't constantly reloading our app. So let's learn best practices now!
The reason this route is used instead of GET /api/users/:id
is two fold. One, we don't know the users id on load, so how could we hit it? And two, read REST routes are supposed to be idempotent (eye-dem-PO-tent) which means "don't change." GET /api/me
will change depending on the auth cookie. So this little example app does have GET /api/users
and GET /api/users/:id
because GET /api/me
is not a replacement for them. They just aren't used in the client yet. But your projects might in the future!
So even though our cookies last as long as the user has the browser open, it's still possible that the cookies get deleted/expired somehow while the user is working. For one thing, they could clear their cache. So in this event we have 2 options:
- Ignore it: This is the "secret message" approach. That route only loads if there is an
authenticated
user (e.g. a cookie exists). But if it fails, we don't really do anything other than not display the message on the client. Sometimes this is what you want. - Redirect: This is the update username route's approach. See, only
authorized
users should be able to view/edit their profile. So if we load the page andapi/me
is good, we render theuser.html
for them. However, if they clear their cache mid session, and then try to update their username, the auth fails. The server refuses to update the info, and then throws a 401 (or 403 if they've maliciously modified the cookie incorrectly). That means they shouldn't even seeuser.html
anymore, so in this case we do actively redirect them away from the page. For sensitive information, this is the approach you should take: the second any request unexpectedly fails auth, kick them out.
Don't worry too much about mid session failures just yet. All you really need to do is make sure that when it comes to updating info on the server, you are always verifying proper auth.
The apps this would be suited for are ones where a user has info and children entities they can access, but they don't interact with other users. Primary reason being is that in order to load a different users and specific assets via the url, we'd have to use queries. And that can get messy quickly. So if we had a dogs.html
we'd just load up all the dogs, and then within the page we could interact individually with them (like or comment on the photo for example).
Given time constraints, this project is handling barely any errors. The model is very brittle right now, the server and sql errors should be handled like we've done before. We're also only handling the most basic of flows and errors on the client. Things like handling attempted recreations of users who already exist or even wrong passwords can be handled much more delicately.