3

I'm trying to write a very simple Express app, which is supposed to serve multiple static folders.

I have a root folder "stories" which contains multiple folders (story-1, story2, etc...). Each story folder contains static assets (scripts, CSS stylesheets, subpages...).

My users can unlock each of those stories, so each story folder must be protected. (If anyone tries to access http://backend/stories/story-1, it should give a 401 Forbidden).

My initial thought was to generate a one-time JWT upfront (like a signed url; not a bearer), add it to query params like http://backend/stories/story-1?jwt=the-jwt-token, then do some backend logic to test if the user has access to this content before serving it.

I tried fiddling with a basic express configuration + a custom authorization middleware :

Project structure :

...
-- /src
-- /stories   ⬅️ custom public folder
   -- /story-1 ⬅️ public but protected
      - index.html
      - /subpages
        -page2.html
        -page3.html
      - /styles
      - /scripts
   -- /story-2 ⬅️ public but protected
      - index.html
      - /subpages
        -page2.html
        -page3.html
      - /styles
      - /scripts
   -- /story-3 ⬅️ public but protected
      - index.html
      - /subpages
        -page2.html
        -page3.html
      - /styles
      - /scripts
etc...

index.js :

const express = require("express");

const { authorized } = require("./middlewares/authorized");
const app = express();
const port = 3000;

app.use("/stories/:story", authorized);
app.use("/stories", express.static(__dirname + "/stories"));

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

authorized.js :

exports.authorized = (req, res, next) => {
  const jwt = req.query.jwt;
  if (!jwt) return res.sendStatus(401);
  // todo : custom logic to test if the user has access to this content, if yes we do next(), if no we return a 401.
  return next();
};

This simple example works partially, when I try to go to http://localhost:3000/stories/first-story (without JWT), I get a 401 (that's ok).

But when I add the jwt : http://localhost:3000/stories/first-story/?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

enter image description here

The middleware runs for every assets that are linked in the index.html, but those assets urls don't have the JWT query params, which leads to a 401.

I guess it's totally normal because that's how middlewares are intended to work. My guess is that i'm configuring express router wrong :

app.use("/stories/:story", authorized);
app.use("/stories", express.static(__dirname + "/stories"));

I would like to run the middleware only once, when any of the /:story subfolders inside /stories are asked to be served.

Nuzzob
  • 371
  • 1
  • 4
  • 23
  • 1
    Try not using JWT as query params but send them as cookies. When valid JWT is present is identified in cookies automatically the correct resources will be available – Kaneki21 Aug 10 '22 at 17:55
  • Thanks, I'll take your advice into consideration ! – Nuzzob Aug 10 '22 at 18:49

4 Answers4

1

You write:

I would like to run the middleware only once, when any of the /:story subfolders inside /stories are asked to be served.

But if every .html (sub-)page is served by a separate HTTP request, each of these requests must be protected, assuming that the HTML contains material that is worthy of protection. (The styles and scripts may not need this extra protection.)

Therefore it is OK that the authorized middleware runs for each such request. And if the JWT was in a cookie (as suggested by Kaneki21), it would be present automatically in each request.

Heiko Theißen
  • 12,807
  • 2
  • 7
  • 31
  • I find it really cumbersome to run the middleware for each assets inside the folder. The realilty here is that the backend that serves the stories is totally disconnected from my other backend that handles the users and the content (I use Strapi headless CMS). So I would have to make an http request to the Strapi server for each assets, that's something that I would like to avoid. Is there really no simpler option ? I was thinking something like : Path matches /stories/:story -> pause and make API call to test authorization -> API call ok then serve the whole folder.. – Nuzzob Aug 13 '22 at 21:24
  • The decision whether "API call ok" _must not_ be made on the client, since that could be circumvented. What you call cumbersome is the price of protecting your resources. – Heiko Theißen Aug 14 '22 at 06:27
0

I would separate out the access control logic from the identity logic. You can use your jwt to verify that the user is who they say the are, and then use your existing knowledge of who that user is to grant them access.

I put a simple example using cookie-backed sessions below, note that you can add sequential middleware a, b, and c all in one function via app.use('/foobar',a,b,c).


// other setup

...

const session = require('express-session'),
           fs = require('fs'),
   { Router } = require('express');

const secret = (() => {
  let secretFile = '/path/to/my/secret.txt';
  try { 
    // try reading a saved secret
    return fs.readFileSync(secretFile,'utf8');
  }
  catch(err) {
    // otherwise generate secret and save it
    let random = require('crypto').randomBytes(128).toString('base64');
    fs.writeFileSync(secretFile,random);
    return random;
  }
})();


// Add the session middleware to the app
app.use(session(
  { secret,
    name: 'stories-and-whatnot',
    cookie: { sameSite: true } }
));

// Create a router for stories and add it to the app
let storyRouter = Router();
app.use('/stories', storyRouter);

// add identity middleware to storyRouter
storyRouter.use( authorized);

let storyMax = 10;
for(let i=0; i<storyMax; i++) {
  // set up the individual story routers
  storyRouter.use(
    `/story-${i}`,
    function(req,res,next) {
      if(!req.session.storyAccess || !req.session.storyAccess[i]) {
        // If the user's session doesn't show it has access, reject with 401
        res.status(401).end(`You do not have access to story ${i}`);
      }
      else {
        // Otherwise let them proceed to the static router
        next();
      }
    },
    express.static(require('path').join(__dirname,`stories/story-${i}`)
  );
}

...

// And somewhere else you have something like this

app.get('/access-granted', authorized, function(req,res,next) {
  let { id } = req.query;
  if(!req.session.storyAccess)
    req.session.storyAccess = {};
  req.session.storyAccess[id] = true;
  res.end(`Access granted to story ${id}`);
});

leitning
  • 1,081
  • 1
  • 6
  • 10
0

You might consider, not using a middleware at all for the serving of content. But rather, to setup the user's set of "approved" paths.

That way a user, failing authentication, would have no valid paths, other then perhaps a preset collection of "base" paths.

This way, later after your authentication middleware the "routing" can be constrained to just that users set of "granted" paths.


Essentially model the access using sessions, which are established on first request, and then updated and maintained as things progress.

rexfordkelly
  • 1,623
  • 10
  • 15
-1

One solution is that you check if the user has the right to view the page on the client side. You'll need some JavaScript on the client side to do this.

You can store the token in LocalStorage after login. Then, at the beginning of the protected HTML file, you include your JS code to retrieve the token, and send a request to the server to check if the user is authenticated or not. Then, based on the response of the server you show the content or hide it.

To be honest, I rarely see the JWT in the URL. People talk about it here, here, here... You should revise your current approach carefully.

Đăng Khoa Đinh
  • 5,038
  • 3
  • 15
  • 33
  • I understand that a JWT in the URL is a wrong approach, I'll try to send it with an Authorization header. But my static stories folders must stay on backend side, because those are paid content and should not be made public on the frontend (even with some logic to show or hide them, they would be part of the code, so they would be public). My goal here is to serve them only if the required checks has been made backend side. – Nuzzob Aug 10 '22 at 18:45
  • Checks on the client side can always be circumvented, so this is not secure. Once the HTML has been received by the browser, malicious users could inspect it with other tools even if the Javascript tries to hide it. – Heiko Theißen Aug 12 '22 at 15:20