✏ How to add a Login Function to your App (4/4)
In part 3, I had cleaned up the backend code to follow Model-View-Controller principles, but for a secure implementation, the routes can't stay like this. I've broken down the principles of secure authorisation here on Day 42, using access and refresh tokens, and now I'd like to build it.
✏ Modification of the User Model
This isn't a requirement, just convenient. Whenever I save a user to the database, I first hash their password. When I fetch a user, I compare this hashed password with the plain text password coming from the login (or register) form.
Automatically hashing passwords: Mongoose Hooks
The first part can be automated by adding a Mongoose Hook. Those are functions that run at a specific moment. For instance, the validate
method in my User Schema that validates the email address is called by a built-in pre('save')
hook.
I'll use the same hook to call a function that automatically hashes the password when (right before, that is) saving the user to the database. Going into usermodel.js (or whereever you've defined your Schema) and adding the following:
userSchema.pre('save', async function (next) {
const salt = await bcrypt.genSalt();
this.password = await bcrypt.hash(this.password, salt);
next();
});
Whenever I create a new instance of the Model and save it to the database, the hook will call the function with this
being the new instance. Note that this also works with the "shortcut" Model.create()
, because .create()
also calls .save()
.
Comparing passwords: Custom Schema methods
It's possible to add methods to the Schema, for example to compare hashed and plain text password:
userSchema.methods.matchPassword = async function (enteredPassword) {
return await bcrypt.compare(enteredPassword, this.password);
};
Example how to use the method, perhaps in a login route:
const { email, password } = request.body;
const user = await User.findOne({ email });
if (user && (await user.matchPassword(password))) {
// login successful
}
With these two modifications, the user controller doesn't need bcrypt
anymore, it's all handled by the Schema now.
✏ Access and Refresh Tokens
When a user signs up or logs in, they get two tokens. I'm already sending an access token that expires after 30 seconds. This is extremely short, and just for testing purposes. Realistically, such a token would expire after 10-15 minutes.
The second token is a refresh token, travelling on an httpOnly
cookie, so it's not accessible by client-side JavaScript. Whenever an access token is about to expire, the client makes a request for a new token, and the server can authorise the user by checking the signed refresh token.
Adding a new function to generate the second token with a different signature/token secret and the user's database id as payload:
generateToken.js
const jwt = require('jsonwebtoken');
const generateAccessToken = id => {
return jwt.sign({ id }, process.env.JWT_SECRET_ACCESS, {
expiresIn: 30 // 30 seconds
});
};
const generateRefreshToken = id => {
return jwt.sign({ id }, process.env.JWT_SECRET_REFRESH, {
expiresIn: 3 * 24 * 60 * 60 // 3 days
});
};
Modifying the login function accordingly (to make this work, first npm-install cookie-parser
and use it in the server file):
const cookieParser = require('cookie-parser')
app.use(cookieParser());
userController.js
const login = asyncHandler(async (req, res) => {
const { email, password } = req.body;
if (email) {
const user = await User.findOne({ email });
// if login successful
if (user && (await user.matchPassword(password))) {
const accessToken = generateAccessToken(user._id);
const refreshToken = generateRefreshToken(user._id);
// send refresh token as httpOnly cookie
res.cookie('refreshjwt', refreshToken, {
httpOnly: true,
maxAge: 3 * 24 * 60 * 60 * 1000,
secure: true
});
res.status(200);
// send access token and user data in response
res.json({
user: {
_id: user._id,
email: user.email
},
accessToken
});
} else {
res.status(401);
throw new Error('invalid email or password');
}
}
});
Since the refresh token expires after 3 days, it makes sense to give the cookie the same lifetime (note that for cookies, maxAge
is measured in milliseconds).
The modifications for the register function are equivalent. The protectRoutes
middleware needs an update to check the validity of the refresh token, because currently, it only validates the access token.
Technically, the access token is the one that allows visiting protected routes, so it might be overkill to also check the refresh token. I've seen examples with both ways, but my reasoning behind it is that if evilCoder9 gets hold of a valid access token, that alone still wouldn't grant them access.
authMiddleware.js
const protectRoutes = asyncHandler(async (req, res, next) => {
// check for a cookie in the headers
const refreshToken = req.cookies.refreshjwt;
// try to verify it
let decodedRefresh;
if (refreshToken) {
try {
decodedRefresh = jwt.verify(
refreshToken,
process.env.JWT_SECRET_REFRESH
);
} catch (err) {
res.status(401);
throw new Error('unauthorised, refresh token invalid');
}
// if refresh token is valid, check the access token
if (decodedRefresh) {
// same as before
}
} else {
res.status(401);
throw new Error('unauthorised, refresh token missing');
}
next();
});
✏ Logout
When a user logs out, the frontend will remove all user data and their current access token from state, and make a GET request to a route /api/users/logout
, to also remove the refresh cookie. To delete a cookie, set it again with the same name, but an empty string as value, and a maxAge
of 0
const logout = asyncHandler(async (req, res) => {
res.cookie('refreshjwt', '', {
httpOnly: true,
maxAge: 0,
secure: true
});
res.json({message: 'logout successful'});
});
At this point, you can install extra security on your server by keeping track of these expended refresh tokens, and check every request against that collection first.
✏ Refreshing the Access Token
Since the access token has a short lifetime, the user would auto-logout from the app every 10-15 minutes, or whenever they reload the page. To prevent that from happening, the frontend will periodically make a silent request to a route /api/users/refresh
whenever the token is about to expire. That route will check the validity of the refresh cookie, similar to the protectRoutes middleware, and send a new access token:
userController.js
const refresh = asyncHandler(async (req, res) => {
// check if a refresh cookie is in the headers
const refreshToken = req.cookies.refreshjwt;
// try to decode it to get the user's id
let decodedRefresh;
if (refreshToken) {
try {
decodedRefresh = jwt.verify(
refreshToken,
process.env.JWT_SECRET_REFRESH);
} catch (err) {
res.status(401);
throw new Error('invalid refresh token, please log in again');
}
// on success, send new access token with the user's id as payload
if (decodedRefresh) {
const accessToken = generateAccessToken(decodedRefresh.id)
.select('-password');
res.json({ user, accessToken });
}
} else {
res.status(401);
throw new Error('session expired, please log in again');
}
});
With the routes to register
, login
, logout
and refresh
now being ready on the backend, I can finally start with the frontend.
Note that you'll likely run into issues when developing locally, with your server running on something like
http://localhost:5000
and the React client being onhttp://localhost:3000
. So at this point, after testing all my routes with Postman, I deployed the backend to Heroku, and the frontend can use those API endpoints now.
✏ React Frontend
I had thought about building this out in entirety, but I don't want to distract from the essential parts, and the actual UI is irrelevant. What's important is that
on first render, the App will make a request to
/api/users/refresh
to check if the user has an active session, and potentially receive a new access token in the responseif they're logged in (or after successfully logging in or signing up), a repeated timeout will make subsequent requests to refresh the access token
if they log out, the App will remove the user's data from state and make a request to
/api/users/logout
Starting with the refresh function:
const [user, setUser] = useState(null);
const refresh = useCallback(async () => {
const url = 'https://your-backend.com/api/users/refresh';
const config = {
withCredentials: true
};
try {
// check for valid refresh cookie on browser
const { data } = await axios.get(url, config);
if (data) {
// on success, store user and token in state
setUser(data.user);
setToken(data.accessToken);
// refresh again before token expires
setTimeout(() => {
refresh();
}, expireTime-1000);
}
} catch (err) {}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
If refresh is unsuccessful when the app mounts, there'll be no subsequent requests, unless the user logs in. For a successful login, the server responds with the user's data, a new access token, and a refresh cookie, and the frontend starts the refresh cycle.
const handleLogin = async e => {
e.preventDefault();
const url = 'https://your-backend.com/api/users/login';
const config = {
withCredentials: true,
headers: {
'Content-Type': 'application/json'
}
};
try {
const { data } = await axios.post(url, { email, password }, config);
if (data) {
setUser(data.user);
setToken(data.accessToken);
setTimeout(() => {
refresh();
}, expireTime-1000);
}
} catch (err) {}
};
And that's it for the basic implementation of frontend user authorisation. Additional routes for PUT requests to let users edit their profile or delete their account would follow the same patterns, so there's no need to include more examples to cover them all.
If I was responsible for building an app that handles sensitive data or financial transactions, I probably wouldn't make that a MERN application, at least not while I'm still a learner and not 100% confident that the app is secure. I might kick the R altogether, and let everything happen on the server, using a template engine, instead of building a flashy SPA.
Some open questions
Why are we using a refresh cookie and an access token, why not two httpOnly cookies? What is easier accessible for an attacker, a cookie or something stored in the application's state?
Are there better ways than saving the token in state?
On a protected route, should the backend already allow access if it finds a valid access token, or always check for the cookie, too? What happens in a large application with many requests?
What about CORS, how to configure the server so it doesn't accidentally open a backdoor?
I'm looking forward to diving deeper into these topics in future posts. I've definitely already learned a lot during this 4 part mini-series, not just about secure logins, but also some skills that are helpful in general when developing a full stack application, so here's an extended recap:
✏ Recap
Code examples:
setting up an Express server with connection to MongoDB Atlas
seeding the database
Express middleware (authorisation and error middleware)
generating and validating JWT
clean server code with Express router, following MVC
using Mongoose hooks and custom methods on the Model instance
Theory and principles:
secure authentication and authorisation on backend and frontend using refresh and access tokens
JWT (JSON Web Tokens)
MVC (Model - View - Controller)
httpOnly cookies
Skills and tools:
Express router
Express async handler
advanced testing with Postman
✏ Thanks for reading!
I do my best to thoroughly research the things I learn, but if you find any errors or have additions, please leave a comment below, or @ me on Twitter. If you liked this post, I invite you to subscribe to my newsletter. Until next time 👋
✏ Previous Posts
You can find an overview of all previous posts with tags and tag search here: