|
| 1 | +# Authorization through middleware |
| 2 | + |
| 3 | +In the previous lesson, we introduced authorization and implemented authentication and authorization guards using if-else statements directly inside the route controllers. This isn't an acceptable approach as the code will be very messy and bug-prone. It is better to define authorization logic outside the controllers, and use middlewares to enforce it. |
| 4 | + |
| 5 | +This lesson objectives are: |
| 6 | + |
| 7 | +- Introduce middleware as a feature in Express.js and many other backend frameworks |
| 8 | +- Implement authentication guard |
| 9 | +- Implement authorization guard |
| 10 | +- Guard certain endpoints with these middlewares |
| 11 | + |
| 12 | +## **Middleware** |
| 13 | + |
| 14 | +Middleware are functions that have access to the [request object](https://expressjs.com/en/4x/api.html#req) (`req`), the [response object](https://expressjs.com/en/4x/api.html#res) (`res`), and the next middleware function in the application’s request-response cycle. The next middleware function is commonly denoted by a variable named `next`. |
| 15 | + |
| 16 | +```js |
| 17 | +const loggerMiddleware = (req, res, next) => { |
| 18 | + // you have full access to the request and response objects |
| 19 | + console.log("Request Type:", req.method); |
| 20 | + // end the middleware by calling next() |
| 21 | + next(); |
| 22 | +}; |
| 23 | +``` |
| 24 | + |
| 25 | +Middleware functions can perform the following tasks: |
| 26 | + |
| 27 | +- Execute any code. |
| 28 | +- Make changes to the request and the response objects. |
| 29 | +- End the request-response cycle. |
| 30 | +- Call the next middleware function in the stack. |
| 31 | + |
| 32 | +If the current middleware function does not end the request-response cycle, it must call `next()` to pass control to the next middleware function. Otherwise, the request will be left hanging. |
| 33 | + |
| 34 | +All the functions we used inside the controllers, or that we passed to the router endpoint, are middleware functions. They were used as the last middleware because we haven't called the `next()` function and ended the cycle by calling `res.render()` or `res.json()`. |
| 35 | + |
| 36 | +An Express application can use the following types of middleware: |
| 37 | + |
| 38 | +- [Application-level middleware](https://expressjs.com/en/guide/using-middleware.html#middleware.application) |
| 39 | +- [Router-level middleware](https://expressjs.com/en/guide/using-middleware.html#middleware.router) |
| 40 | +- [Error-handling middleware](https://expressjs.com/en/guide/using-middleware.html#middleware.error-handling) |
| 41 | +- [Built-in middleware](https://expressjs.com/en/guide/using-middleware.html#middleware.built-in) |
| 42 | +- [Third-party middleware](https://expressjs.com/en/guide/using-middleware.html#middleware.third-party) |
| 43 | + |
| 44 | +You can load application-level and router-level middleware with an optional mount path. You can also load a series of middleware functions together, which creates a sub-stack of the middleware system at a mount point. Check out Express documentation regarding [using middleware](https://expressjs.com/en/guide/using-middleware.html). |
| 45 | + |
| 46 | +We've seen middleware in previous lessons when we used the `express-session` and `express-jwt`. They are application-level middleware that we used to create the session object or the user object inside `req` that we subsequently used in the controller. By default, `req` wouldn't contain `session` or `user` however, in the case of `express-session` the following simplified steps were done for _every_ request: |
| 47 | + |
| 48 | +- Read the cookies from `req` and grab the `sessionID`. |
| 49 | +- If no `sessionID` is found in the request's cookies: |
| 50 | + - Create a new session and grab its ID. |
| 51 | + - Queue a `Set-Cookie` header with `sessionID` to the next response by modifying `res`. |
| 52 | +- Else read the session associated with requests `sessionID` from the session adapter. |
| 53 | +- Append the created or read `session` to `req`. |
| 54 | +- Call `next()` to let the app continue its req-res cycle. |
| 55 | + |
| 56 | +### **Middleware precedence** |
| 57 | + |
| 58 | +In Express.js middleware are applied in their order of use. Remember that every function that has `req` and `res` is a middleware function. And the middleware stack is established when you use `app.use` or `router.use`. |
| 59 | + |
| 60 | +```js |
| 61 | +app.use(session()); |
| 62 | +app.use(logger()); |
| 63 | +``` |
| 64 | + |
| 65 | +In the code above, the `express-session` middleware will be executed first, then when it calls `next()`, the `logger` middleware will be executed till it calls `next()` as well, and so on. |
| 66 | + |
| 67 | +So it is important whenever you define your middleware that you make sure the required middleware for it are executed before it. |
| 68 | + |
| 69 | +For example, in your `onlyAuthenticated` middleware, you will need to check `req.session.user` (or `req.user` if you use `express-jwt`). Hence, it is important that the session middleware is applied before your middleware. Otherwise, you won't have `session` inside `req`. |
| 70 | + |
| 71 | +### **Middleware mount-path** |
| 72 | + |
| 73 | +Middleware can be scoped as well. It can be for every request, or for specific endpoints, or even specific HTTP methods: |
| 74 | + |
| 75 | +```js |
| 76 | +app.use(function (req, res, next) { |
| 77 | + // will be called on every request. Application wide mount path |
| 78 | + next(); |
| 79 | +}); |
| 80 | + |
| 81 | +app.use("/dashboard", function (req, res, next) { |
| 82 | + // will be called on all request to /dashboard |
| 83 | + next(); |
| 84 | +}); |
| 85 | + |
| 86 | +app.get("/user", function (req, res, next) { |
| 87 | + // will be invoked only when a GET requesting /user endpoint |
| 88 | + next(); |
| 89 | +}); |
| 90 | +``` |
| 91 | + |
| 92 | +You can even enforce the middleware on only specific router instances and controllers: |
| 93 | + |
| 94 | +```js |
| 95 | +// ./routes/user.js |
| 96 | +const express = require("express"); |
| 97 | +const router = express.Router(); |
| 98 | + |
| 99 | +router.use(function (req, res, next) { |
| 100 | + // will be called on all routes in this file ONLY |
| 101 | + next(); |
| 102 | +}); |
| 103 | +``` |
| 104 | + |
| 105 | +### **Error-handling middlware** |
| 106 | + |
| 107 | +All the middleware we've seen so far take only **three** arguments, `req`, `res`, and `next`. You can define an error-handling middleware by providing **four** arguments: `err`, `req`, `res`, and `next`. These middleware are important to handle errors that are thrown in your app and provide a safe fallback response to clients without exposing your app's security. |
| 108 | + |
| 109 | +```js |
| 110 | +app.use(function (err, req, res, next) { |
| 111 | + console.error(err.stack); |
| 112 | + res.status(500).send("Something broke!"); |
| 113 | +}); |
| 114 | +``` |
| 115 | + |
| 116 | +Usually, an error logging service like [sentry](https://sentry.io/) is also contacted in this middleware to log and track the error so you get notified when your app has run into an unhandled exception, with the required request-response details that can help the developers reproduce the error to fix it. |
| 117 | + |
| 118 | +> ⚠️ **Warning**: Error-handling middleware always takes **four** arguments. You must provide four arguments to identify it as an error-handling middleware function. Even if you don’t need to use the next object, you must specify it to maintain the signature. Otherwise, the next object will be interpreted as regular middleware and will fail to handle errors. |
| 119 | +
|
| 120 | +## **Auth guard middleware** |
| 121 | + |
| 122 | +To implement `onlyAdmins`, `onlyModerators`, and `onlyAuthenticated` middleware, we create a new middleware containing source file: |
| 123 | + |
| 124 | +```js |
| 125 | +// ./middlware/index.js |
| 126 | + |
| 127 | +function onlyAuthenticated(req, res, next) { |
| 128 | + if (req.session.user) { |
| 129 | + next(); // user is authenticated |
| 130 | + } else { |
| 131 | + res.status(401).redirect("/login"); |
| 132 | + } |
| 133 | +} |
| 134 | + |
| 135 | +function onlyAdmins(req, res, next) { |
| 136 | + const user = req.session.user; |
| 137 | + |
| 138 | + if (user && user.isAdmin) { |
| 139 | + next(); |
| 140 | + } else { |
| 141 | + res.status(401).json({ error: "Unauthorized" }); |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +function onlyModerators(req, res, next) { |
| 146 | + const user = req.session.user; |
| 147 | + if (user && (user.isAdmin || user.isModerator)) { |
| 148 | + next(); |
| 149 | + } else { |
| 150 | + res.status(401).json({ error: "Unauthorized" }); |
| 151 | + } |
| 152 | +} |
| 153 | + |
| 154 | +module.exports = { onlyAuthenticated, onlyAdmins, onlyModerators }; |
| 155 | +``` |
| 156 | + |
| 157 | +then inside our router, we can guard it by applying the middleware **before** the endpoint handler function: |
| 158 | + |
| 159 | +```js |
| 160 | +// ./routes/role.js |
| 161 | +const express = require("express"); |
| 162 | +const router = express.Router(); |
| 163 | +const { User } = require("../db"); // as exported in the db file |
| 164 | +const { onlyAdmins } = require('../middleware'); |
| 165 | + |
| 166 | +router.post("/elevate", onlyAdmins, async (req, res) => { |
| 167 | + const { subject_id, role_id } = req.body; |
| 168 | + |
| 169 | + // check role_id is correct |
| 170 | + const roles = Object.values(User.roles); |
| 171 | + if (!roles.includes(parseInt(role_id, 10))) { |
| 172 | + res.status(400).json({ |
| 173 | + error: `role_id is incorrect. Accepted values are: ${roles.join(", ")}`, |
| 174 | + }); |
| 175 | + return; // exit |
| 176 | + } |
| 177 | + |
| 178 | + // check subject to be elevated exists |
| 179 | + // we will query users with defaultScope to exclude password |
| 180 | + const subject = await User.findByPk(subject_id); |
| 181 | + |
| 182 | + if (!subject) { |
| 183 | + res.status(400).json({ |
| 184 | + error: `User with id: ${subject_id} cannot be found.`, |
| 185 | + }); |
| 186 | + } else { |
| 187 | + subject.role_id = parseInt(role_id, 10); |
| 188 | + await subject.save(); // save dirty instance to database |
| 189 | + res.status(202).json({ status: "OK" }); |
| 190 | + } |
| 191 | +} |
| 192 | +``` |
| 193 | +
|
| 194 | +Because we are using the `onlyAdmins` middleware, there is no need to check if the user is logged in or is an admin. The code above won't be executed if the user isn't authorized. |
| 195 | +
|
| 196 | +> ⚠️ **Warning**: When you apply a middleware, you simply pass it as a **function**. Above we passed `onlyAdmins` without `()` parentheses. Middleware can be implemented using a configuration function as well, which is a function that takes configuration arguments and returns a middleware function. Read more about it in Express [documentation](https://expressjs.com/en/guide/writing-middleware.html#:~:text=Using%20Express%20middleware.-,Configurable%20middleware,-If%20you%20need). |
| 197 | +
|
| 198 | +## **Conclusion** |
| 199 | +
|
| 200 | +In this lesson, we learned that everything in Express.js is basically a middleware that gets invoked somewhere. And by using middleware, we can structure our business logic in a clean approach. Middleware can be used for basically everything, and one of their use cases is to enforce authorization. |
0 commit comments