expressjs/session

Any good ways to refresh database data with a session?

TheDogHusky opened this issue · 4 comments

Hey.

I am working for a friend and making him a website with a dashboard and discord account connexion.
I am recently facing a problem in the writing of the code: I have no idea how can I make this work, let me explain.

I have a /login route which redirects to the discord page.
Then discord redirectsto /login with the code (the OAuth2 code), which handles it, gets the access token and user information and it all to the session using session.save() after updating the properties as well as creating or getting a DB entry so I can have the data associated with the Discord account (for example: the staffLevel (used for the dashboard), website roles, etc..).
But now I face a problem: how can I update the session at each request in a correct and optimized way to get the database data?
Because if someone updates the data on the dashboard (eg: disables his account, changes the staffLevel), I need it to be accessible so I can deal with these changes.
I might be unclear, and I'm sorry for that, the ideas are not clear in my head too, don't hesitate to ask for more details.

I guess for now some sort of middleware that does session.reload() could work, with then a token validity check, but I know that mine won't be optimized and that's why I'm asking y'all how can I make that secure and optimized.

For the database I use a MongoDB Atlas with Mongoose for the backend. Also I don't think storing the avatarURL, the username and other things could be useful in the database as it is already stored in the session.

So any ideas?

Here are some current code snippets:
src/structures/functions.ts (contains some middlewares):

/**
 * Utility middleware to require a login to access a route
 * @param req
 * @param res
 * @param next
 */
export async function requireLogin(req: Request, res: Response, next: NextFunction): Promise<void> {
    if(req.session.user && (await CheckToken(req.session.token))) next();
    else {
        req.session.redirectTo = req.path;
        res.redirect('/login');
    }
}

/**
 * Utility middleware to require a certain staff level to access a route
 * @param staffLevel
 * @param redirect
 */
export function requireAdmin(staffLevel: number, redirect = "/") {
    return async (req: Request, res: Response, next: NextFunction) => {
        if(req.session.dbusr.staffLevel >= staffLevel) next();
        else {
            req.flash('error','Vous n\'avez pas les permissions pour accéder à cette ressource.');
            res.redirect(redirect);
        }
    };
}

/**
 * Utility middleware to update the session with database information
 * @param req
 * @param res
 * @param next
 */
export async function sessionHandler(req: Request, res: Response, next: NextFunction): Promise<void> {
    // Check if the user is logged in, if not there is no need to update the session with information from the database
    if (req.session.user) {
        const dbusr = await Users.findOne({ id: req.session.user?.id });
        if(!dbusr) {
            res.redirect('/logout');
        }
        if(req.session.dbusr) req.session.dbusr = dbusr;

        if(req.session.token && !await CheckToken(req.session.token)) {
            res.redirect('/logout');
        }
    }

    next();
}

src/structures/app.ts (only middleware initialization):

// inside a class App
private initializeMiddlewares() {
        this.app.use(express.json());
        this.app.use(express.urlencoded({ extended: true }));
        this.app.set('view engine', 'ejs');
        this.app.set('views', path.join(__dirname, '..', '..', 'views'));
        this.app.use(Helmet({
            contentSecurityPolicy: {
                directives: { //fix the xmlns error

                    defaultSrc: ["'self'"],
                    scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.jsdelivr.net", "https://code.jquery.com"],
                    styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"],
                    imgSrc: ["'self'", "'unsafe-inline'", "https://cdn.discordapp.com", "data:"],
                    connectSrc: ["'self'", "https://discord.com", "https://discordapp.com"],
                    fontSrc: ["'self'", "https://fonts.gstatic.com", "https://fonts.googleapis.com"],
                    frameSrc: ["'self'", "https://discord.com", "https://discordapp.com"],
                    objectSrc: ["'none'"],
                    mediaSrc: ["'none'"],
                    frameAncestors: ["'none'"],
                    formAction: ["'self'"],
                    upgradeInsecureRequests: []
                }
            }
        }));
        this.app.use(cors());
        this.app.use(Compression());
        const sess: session.SessionOptions = {
            secret: config.secret,
            resave: false,
            saveUninitialized: true,
            cookie: {
                maxAge: 60 * 60 * 24 * 7 * 1000,
            },
            store: new MongoStore({
                mongoUrl: config.mongoUrl,
                collectionName: 'sessions',
                ttl: 60 * 60 * 24 * 7
            })
        };
        if (this.app.get('env') === 'production') {
            this.app.set('trust proxy', 1);
             if(sess.cookie) sess.cookie.secure = true;
        }
        this.app.use(session(sess));
        this.app.use(Flash());
        this.app.use('/static', express.static('static'));
        this.app.set('host', config.host);
        this.app.use((req, res, next) => {
            next();
            const ms = utils.getMs(process.hrtime());
            if(!utils.isGoodStatus(res.statusCode)) return this.logger.warn(`${req.method} @${req.url} - ${res.statusCode} (${utils.timingColor(ms)})`, "HTTP");
            this.logger.info(`${req.method} @${req.url} - ${res.statusCode} (${utils.timingColor(ms)})`, "HTTP");
        });
        this.app.use(sessionHandler);
        const api = Api(this.logger);
        this.app.use('/api', api);
        this.app.locals = {
            functions: utils,
            config: config,
            logger: this.logger,
            types: types,
            moment: require('moment')
        }
    };
    ```
Here is what my session takes:
```ts
    declare module "express-session" {
    interface SessionData {
        user: oauth.User;
        token: string;
        redirectTo: string;
        token_expire: number;
        avatar: string;
        dbusr: any;
        state: string;
    }
}

And here is the User schema:

import mongoose from 'mongoose';

export default mongoose.model('User', new mongoose.Schema({
    id: { type: String, required: true },
    staffLevel: { type: Number, default: 0 },
    applications: { type: Array, default: [] },
    username: { type: String, required: true },
    avatar: { type: String, required: true },
    createdAt: { type: Date, default: Date.now },
    updatedAt: { type: Date, default: Date.now },
    email: { type: String, default: null },
    roles: { type: Array, default: [] },
    disabled: { type: Boolean, default: false },
}));

Everything works fine but I'd like an optimized way to synchronise the data from the session and the database.

As well as sometimes the flash messages don't get updated before the page redirects, and appears after a refresh.

Thanks for reaching out! Just a heads up, we usually use GitHub issues for bug reports and feature requests. While we'd love to help everyone, we can't offer in-depth support for individual apps here. For questions like yours, Stack Overflow, reddit, or similar forums might be your best bet.

that said the root of your issue is that you're conflating authentication and authorization, and have identified that you're using sessions in a way which has performance costs. you've implemented an API and are using discord for authentication, but if you don't haven an authorization layer you'll want to implement one. then you can drop the more dynamic data from your session and only check perms when the user attempts an action which requires verifying them, driving down the cost of checking them on every req/res lifecycle. sessions are sort of infrastructure, plumbing state between http requests, but perms are business logic

Here's some LLM output that is meant to expand on the above:

Authentication

Authentication is the process of verifying who a user is. In your case, this is handled through the Discord OAuth2 login flow. When a user logs in, you authenticate them and create a session to maintain their logged-in state across requests. The session typically stores user identity information, such as a user ID or token, which you're doing correctly.

Authorization

Authorization is the process of verifying what an authenticated user is allowed to do. In your application, this relates to checking things like staffLevel and other permissions to determine what resources a user can access or what actions they can perform.

The Conflation Issue

The conflation arises when you're using the session not just to maintain user identity (authentication) but also to store and check authorization information (staffLevel, roles, etc.). While it's common to include some basic role information in a session, relying on the session for real-time permission checks can lead to issues.

Role of HTTP Sessions

  1. State Management: HTTP is stateless, meaning each request is independent of the others. Sessions provide a way to store user-specific data across requests, creating a "stateful" experience for the user. For example, once a user is authenticated, the session maintains this state, so the user doesn't need to log in again for each subsequent request.
  2. User Identification: Sessions help identify the user making the request. By storing a unique session ID on the client (usually in a cookie) and mapping it to session data on the server, the application can recognize the user across multiple requests.
  3. Data Storage: While sessions can store data, this capability should be used judiciously. Sessions are ideal for storing data that doesn't change often and is relatively lightweight, such as user IDs, token references, or basic profile information.

Distinction from Authorization

Authorization, determining what an authenticated user is allowed to do, is a separate concern. While session data can help in identifying the user, which is a prerequisite for authorization, the actual permission checks should ideally be dynamic and reflect the current state of the user's permissions.

  • Dynamism: Permissions can change frequently and need to be checked against a current, authoritative source. Relying on sessions, which may not be updated in real-time, can lead to situations where a user has outdated permissions.
  • Separation of Concerns: By keeping authentication (session management) and authorization (permission checks) separate, you ensure that each function is handled optimally. Sessions maintain user state and identity, while separate mechanisms handle the real-time evaluation of permissions.

In the context of your application, while HTTP sessions are effectively maintaining user authentication, leveraging them to handle dynamic authorization data like staffLevel can lead to issues with data staleness and scalability. To address this, authorization checks should be performed against a live, up-to-date source, ensuring that changes in user permissions are immediately reflected in the application's behavior without needing to rely on the session data being updated with each request.

Thank you very much for that explanation. I now understand the difference.
I’ve got still some questions but I guess it’s better to ask them on a forum?

Glad it helped! Yes, you're more likely to get answers on a forum somewhere than from someone triaging bug tickets across the expressjs org