#100DaysOfMERN - Day 43

#100DaysOfMERN - Day 43

·

9 min read

✏ 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 on http://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 response

  • if 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

  1. 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?

  2. Are there better ways than saving the token in state?

  3. 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?

  4. 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:

#100DaysOfMERN - The App