Practice setting up user accounts and authentication in a Node web app.
Make sure you have Git and Node (v18) installed.
- Use this template, clone your copy, cd into it
- Run
npm install
to install all the dependencies - Run
npm run seed
to seed the database with some example data - Run
npm run dev
to start the server.
This uses thenodemon
library to auto-restart the server when you save changes.
This app already has the routes and templates created. However the sign up/log in functionality is not implemented, and private pages are visible to anyone. You'll need to fix this.
Each challenge has associated unit tests. You can either run all the tests with npm test
, or each individual challenge's tests with npm run test:1
, npm run test:2
etc.
Make sure you read test failures carefully—the output can be noisy but the error message should provide useful information to help you.
This app let's users store their private confessions. It has tables for users, sessions and confessions. It's helpful to know the structure of the database before working with it. You can either read database/schema.sql
, or expand the sections below.
users
column | type | constraints |
---|---|---|
id | integer | primary key autoincrement |
text | unique | |
hash | text | |
created_at | datetime | DEFAULT CURRENT_TIMESTAMP |
sessions
column | type | constraints |
---|---|---|
id | integer | primary key autoincrement |
user_id | integer | references users(id) |
expires_at | datetime | not null |
created_at | datetime | DEFAULT CURRENT_TIMESTAMP |
confessions
column | type | constraints |
---|---|---|
id | integer | primary key autoincrement |
content | text | |
user_id | integer | references users(id) |
created_at | datetime | DEFAULT CURRENT_TIMESTAMP |
Before you start implementing features you need a way to create new sessions in the DB. Fill out the createSession
function in src/model/session.js
. It should:
- Take the user's ID as an argument
- Generate a strong, long, random string to use as the session ID
- Insert a new session into the database (including the user ID)
- Ensure the
expires_at
column is set to a time in the future - Return the generated ID (this will be needed to store in a cookie later)
Show solution
const insert_session = db.prepare(/*sql*/ `
INSERT INTO sessions (id, user_id, expires_at)
VALUES ($id, $user_id, DATE('now', '+7 days'))
`);
function createSession(user_id) {
const id = crypto.randomBytes(18).toString("base64");
insert_session.run({ id, user_id });
return id;
}
The app currently has no way to sign up for a new account. There is a sign up form at GET /sign-up
, but you need to fill out the POST /sign-up
handler to make this feature work.
- Use the
bcryptjs
library to hash the password the user submitted - Use the
createUser
function frommodel/user.js
to insert a new user into the DB - Use the
createSession
function you wrote to insert a new session into the DB - Set a signed
sid
cookie containing the session ID - Redirect to the new user's confession page (e.g.
/confessions/11
)
Show solution
function post(req, res) {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).send("Bad input");
} else {
bcrypt.hash(password, 12).then((hash) => {
const user = createUser(email, hash);
const session_id = createSession(user.id);
res.cookie("sid", session_id, {
signed: true,
maxAge: 1000 * 60 * 60 * 24 * 7, // 1 week
sameSite: "lax",
httpOnly: true,
});
res.redirect(`/confessions/${user.id}`);
});
}
}
The app currently has no way to log in to an existing account. There is a log in form at GET /log-in
, but you need to fill out the POST /log-in
handler to make this feature work.
-
Use
getUserByEmail
frommodel/user.js
to get the user who is trying to log in -
If there's no user with that email send a
400
error response -
Compare the submitted password to the stored user's hash
-
If they don't match send the same error response
Important: don't say exactly what went wrong, otherwise you'll give attackers information like which emails have accounts in your app
-
If they match use the
createSession
function you wrote to insert a new session into the DB -
Set a signed
sid
cookie containing the session ID -
Redirect to the new user's confession page (e.g.
/confessions/11
)
Show solution
function post(req, res) {
const { email, password } = req.body;
const user = getUserByEmail(email);
if (!email || !password || !user) {
return res.status(400).send("<h1>Login failed</h1>");
}
bcrypt.compare(password, user.hash).then((match) => {
if (!match) {
// Same error as above so attacker doesn't know if email exists or password is wrong
return res.status(400).send("<h1>Login failed</h1>");
} else {
const session_id = createSession(user.id);
res.cookie("sid", session_id, {
signed: true,
maxAge: 1000 * 60 * 60 * 24 * 7, // 1 week
sameSite: "lax",
httpOnly: true,
});
res.redirect(`/confessions/${user.id}`);
}
});
}
This app stores sensitive user info, so it's important to let them log out. You'll need to add a log out button to the GET /
, but only visible when they're logged in.
- Read the session ID from the signed cookie
- Get the session from the DB
- If the session exists render a log out form that submits a request to
POST /log-out
- Else render the sign up/log in links
Show solution
function get(req, res) {
const sid = req.signedCookies.sid;
const session = getSession(sid);
const title = "Confess your secrets!";
const content = /*html*/ `
<div class="Cover">
<h1>${title}</h1>
${
session
? /*html*/ `<form method="POST" action="/log-out"><button class="Button">Log out</button></form>`
: /*html*/ `<nav><a href="/sign-up">Sign up</a> or <a href="/log-in">log in</a></nav>`
}
</div>
`;
const body = Layout({ title, content });
res.send(body);
}
Then you need to edit the POST /log-out
handler to make this work.
- Get the session ID from the signed cookie
- Use the
removeSession
function frommodel/session.js
to delete the session from the DB - Clear the
sid
cookie - Redirect back to the homepage
Show solution
function post(req, res) {
const sid = req.signedCookies.sid;
removeSession(sid);
res.clearCookie("sid");
res.redirect("/");
}
Now that you've got user accounts working you need to make sure each user's confessions page is protected. Only the user who owns the page should be able to see it. Edit the GET /confessions/:user_id
handler to make this work.
- Get the session ID from the signed cookie
- Use the
getSession
function frommodel/session.js
to get the session from the DB - Get the logged in user's ID from the session
- Get the page owner from the URL params
- If the logged in user is not the page owner send a
401
error response
Show solution
function get(req, res) {
const sid = req.signedCookies.sid;
const session = getSession(sid);
const current_user = session && session.user_id;
const page_owner = Number(req.params.user_id);
if (current_user !== page_owner) {
return res.status(401).send("<h1>You aren't allowed to see that</h1>");
}
// ...
}
Currently anyone can send a request to e.g. POST /confessions/8
to create confessions on that user's page. This is bad! We can't rely on the URL params for this—we can only trust the user ID in the cookie. Confessions should always submit to the logged in user's account.
- Get the session ID from the signed cookie
- Get the session from the DB
- Get the logged in user's ID from the session
- If there is no logged in user send a
401
error - Use this user ID to create the confession in the DB
- Redirect back to the logged in user's confession page
Show solution
function post(req, res) {
const sid = req.signedCookies.sid;
const session = getSession(sid);
const current_user = session && session.user_id;
if (!req.body.content || !current_user) {
return res.status(401).send("<h1>Confession failed</h1>");
}
createConfession(req.body.content, current_user);
res.redirect(`/confessions/${current_user}`);
}
Your app should now have quite a lot of repeated logic to access the current session. For example the home.js
, confessions.js
and log-out.js
handlers all follow the same process (read the cookie, get the session). It would be nice to abstract this code into an Express middleware, so it can just happen once.
- Write a new middleware function in
server.js
- It should take 3 arguments,
req
,res
andnext
- Read the signed cookie to get the session ID
- Get the session from the DB
- If the session exists attach it to the
req
object - Always call the
next
function to pass the request on to the next handler in the queue - Tell Express to use the middleware before every request
Show solution
server.use(sessions);
function sessions(req, res, next) {
const sid = req.signedCookies.sid;
const session = getSession(sid);
if (session) {
req.session = session;
}
next();
}
Now you can edit any handlers that access the session to use req.session
instead of reading it from the cookie/DB themselves. E.g for log-out.js
:
function post(req, res) {
removeSession(req.session.id);
res.clearCookie("sid");
res.redirect("/");
}
Ideally if a session has expired we want to remove the session and cookie. Centralising this logic also means you can handle expiry in one place, rather than having to copy it to everywhere you read the cookie.
- Edit the middleware to check the sessions
expires_at
property - If this date is prior to the current date then remove the session from the DB and delete the cookie
Show solution
function sessions(req, res, next) {
const sid = req.signedCookies.sid;
const session = getSession(sid);
if (session) {
const expiry = new Date(session.expires_at);
const today = new Date();
if (expiry < today) {
removeSession(sid);
res.clearCookie("sid");
} else {
req.session = session;
}
}
next();
}