@os-team/session
v1.0.45
Published
Reliable, feature-rich, easy-to-use session middleware for Express, developed based on the OWASP recommendations. Stores sessions in Redis. 100% test coverage.
Downloads
108
Readme
@os-team/session
Reliable, feature-rich, easy-to-use session middleware for Express, developed based on the OWASP recommendations. Stores sessions in Redis. 100% test coverage.
Features
- Has a method to destroy all user's sessions.
- Has a method to get a list of all user's session.
- Can send a new session ID and its expiration date not only using a cookie, but also using a custom header (e.g.
X-Token
andX-Token-Expires-At
). It can be useful for mobile apps. - Prevents session fixation attacks.
- Prevents brute-force attacks.
- Prevents guessing attacks.
- Has not only absolute timeout, but also an idle timeout and a renewal timeout.
- Prevents to create too many sessions for a single user.
Usage
Install the package using the following command:
yarn add @os-team/session
It is assumed that the express and ioredis libraries are already installed.
If you use TypeScript, create the express.d.ts
file with the following content:
import { Session } from '@os-team/session';
declare global {
namespace Express {
interface Request {
session: Session;
}
}
}
Simple example
import IORedis from 'ioredis';
import express from 'express';
import session from '@os-team/session';
const redis = new IORedis({
port: 6379,
host: 'localhost',
});
const app = express();
app.use(session({ redis })); // Add the middleware
app.get('/', (req, res) => {
res.send(`User ID: ${req.session.data.userId}`);
});
Creating a new session
When a user sign in or register in your app you need to create a new session for him. You can do it the following way:
// The sign in page
app.post('/sign-in', async (req, res) => {
// Find the user
const user = await db.findUser({ email: req.body.email });
if (!user) {
res.send('The user does not exist');
return;
}
if (!PasswordUtil.compare(req.body.password, user.password)) {
res.send('Password is incorrect');
return;
}
// Create a new session
await req.session.create({ userId: user.id }); // usedId is a required parameter
res.redirect(302, '/account');
});
// The account page
app.get('/account', (req, res) => {
// Redirect unauthenticated users to the sign in page
if (!req.session.id) {
res.redirect(302, '/sign-in');
return;
}
// Use the session data
console.log(req.session.id); // Session ID
console.log(req.session.data.userId); // User ID
console.log(req.session.data.createdAt); // Timestamp when the session was created
console.log(req.session.data.regeneratedAt); // Timestamp when was the last time the session ID was regenerated (see renewalTimeout)
console.log(req.session.data.lastSeenAt); // Timestamp when was the last time the user made a request to the server
console.log(req.session.expiresIn); // Number of seconds after which the session will expire
res.send('You are authenticated!');
});
Fell free to save additional information about a user.
await req.session.create({
userId: user.id,
ip: req.ip,
userAgent: req.get('user-agent'),
});
The new session ID will be passed to the client using a cookie (the Set-Cookie
header).
In addition, to prevent caching the session ID the library sends the Cache-Control: no-store
(for HTTP/1.1 clients) and Pragma: no-cache
(for HTTP/1.0 clients) directives. See more here.
⚠️ Creating a new session is mandatory regardless of whether the user is authenticated (an existing session ID was passed) or not. Otherwise, an attacker can gain access to the user account by using a session fixation attack.
Paragraph 5.1 of the session fixation vulnerability paper reads as follows:
Web applications must ignore any session ID provided by the user's browser at login and must always generate a new session to which the user will log in if successfully authenticated.
Customizing the cookie name
The cookie name should be such that it is not clear what the purpose of this cookie is. The less descriptive the cookie name, the better.
By default, used the sid
cookie name, but you can set your own by passing the cookieName
option in the middleware:
app.use(
session({
redis,
cookieName: 'id',
})
);
Customizing the cookie options
By default, used the following cookie options:
httpOnly
to forbid JS from accessing the cookie. It prevents the session ID stealing through XSS attacks.secure
to enforce a browser sends the cookie to the server only using the HTTPS scheme (only in the production environment). It prevents the disclosure of the session ID through man-in-the-middle attacks.sameSite: 'strict'
to enforce a browser sends the cookie only for same-site requests. It provides some protection against cross-site request forgery attacks.
You can set additional cookie options or change existing ones by passing cookieOptions
:
app.use(
session({
redis,
cookieOptions: {
domain:
process.env.NODE_ENV === 'production' ? 'domain.com' : 'localhost',
path: '/path',
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax',
},
})
);
Recommendations:
- Do not set the
domain
attribute to restrict the cookie just to the origin server. - Set the
path
attribute as narrow as possible only for the path of your web application that uses the session ID.
See more in OWASP.
Passing the session ID using a custom header
If you are developing a mobile app, most likely you want to get the session ID using a custom header (e.g. X-Token
).
To do this, set the tokenHeaderName
option:
app.use(
session({
redis,
tokenHeaderName: 'X-Token',
})
);
In this case, the expiration date of the new session will be passed using the X-Token-Expires-At
header, but you can also customize it:
app.use(
session({
redis,
tokenHeaderName: 'X-Token',
tokenExpirationHeaderName: 'X-Token-Exp',
})
);
Changing the length of the session ID
To prevent brute-force attacks the length of session IDs must be at least 128 bits
.
In addition, to prevent guessing attacks it is necessary to use a cryptography secure pseudo-random number generator (CSPRNG), which ensures that the random numbers coming from it are completely unpredictable.
To generate the session ID this library uses nanoid, which used A-Za-z0-9_-
symbols (1 character is 6 bits
).
Nanoid uses the crypto.getRandomValues
method, which generates cryptographically strong random values.
By default, the length of session IDs is 50 or 300 bits
, but you can reduce or increase it by passing the length
option:
app.use(
session({
redis,
length: 30, // 180 bits
})
);
⚠️ If your app already has active sessions, and you want to reduce the length of the session IDs you MUST also pass the maxLengthExistingIds
option:
app.use(
session({
redis,
length: 30,
maxLengthExistingIds: 50, // The maximum length of session IDs already stored in Redis
})
);
Otherwise, session IDs sent by users will not be detected, because the library validates the length of the session ID before checking for its existence.
By default, the maxLengthExistingIds is equal to the length, so if you reduce the length
and your app has active sessions, set also the maxLengthExistingIds
option.
The OWASP recommendation about validating the session ID.
From Redis keys section:
Very long keys are not a good idea. For instance a key of 1024 bytes is a bad idea not only memory-wise, but also because the lookup of the key in the dataset may require several costly key-comparisons.
The library also checks that the session ID does not contain a colon character before checking for its existence.
Limiting the max number of sessions per user
By default, the library stores no more than 100
sessions per user.
If the number of active sessions exceeds the specified number, the oldest ones are deleted.
This number should not be too large if you send a list of all user sessions to the client side.
You can change the maximum number of sessions per user by passing the maxSessionCountPerUser
middleware option:
app.use(
session({
redis,
maxSessionCountPerUser: 10,
})
);
Updating the current session
In some cases, you may need to update the session data. For example, if you store a username in a session, and the user updates it on the settings page, you can also update it in the session:
app.post('/update-settings', async (req, res) => {
// Redirect unauthenticated users to the sign in page
if (!req.session.id) {
res.redirect(302, '/sign-in');
return;
}
// Update the session data
await req.session.update({
fullName: req.body.fullName,
});
res.send('Your name have been saved');
});
If you want to delete the existing field, you can do it the following way:
await req.session.update({
fullName: undefined,
});
Note that you can not update system fields:
- userId
- createdAt
- regeneratedAt
- lastSeenAt
If you try to do this, these fields will not be saved.
⚠️ If you want to update the user role, you MUST also regenerate the session ID using the req.session.regenerateId
method (see below).
Regenerating the session ID
If you update the user's privilege level (for example, the user has become an administrator), or change the user's password, you must regenerate the session ID to prevent session fixation attacks. Read more about this in OWASP.
Let's assume you want to update the user's password. After changing the password, regenerate the session ID as follows:
app.post('/update-password', async (req, res) => {
// Redirect unauthenticated users to the sign in page
if (!req.session.id) {
res.redirect(302, '/sign-in');
return;
}
// Find a user
const user = await db.findUser({ id: req.session.id });
// Check if the current password is correct
if (!PasswordUtil.compare(req.body.password, user.password)) {
res.send('Password is incorrect');
return;
}
// Update the user's password in the database
user.password = PasswordUtil.hash(req.body.newPassword);
await user.save();
// Regenerate the session ID
await req.session.regenerateId();
res.send('Your password has been changed');
});
The new session ID will be passed to the client using a cookie.
You can also pass the new session ID using a custom header (see the tokenHeaderName
option above).
The old session will be deleted immediately, but in some cases you can make the old session valid for some time, accommodating a safety interval, before the client is aware of the new session ID.
You can do this by passing the deleteAfterDelay
argument to the regenerateId
method like this:
await req.session.regenerateId(true); // deleteAfterDelay = true
By default, the old session will be valid for 60
seconds, but you can set your own number of seconds in the deletionTimeout
middleware option:
app.use(
session({
redis,
deletionTimeout: 20, // seconds
})
);
⚠️ Do NOT use the deleteAfterDelay
argument of the regenerateId
method when updating the user's previlege level or changing the user's password. In this case, the old session MUST be deleted immediately.
Deleting the current session
If the user sign out, you must delete the current session as follows:
app.post('/sign-out', async (req, res) => {
// Redirect unauthenticated users to the sign in page
if (!req.session.id) {
res.redirect(302, '/sign-in');
return;
}
// Delete the current session
await req.session.destroy();
res.redirect(302, '/sign-in');
});
To reset the cookie in the user's browser, the library sends the following header:
Set-Cookie: sid=; Expires=Thu, 01 Jan 1970 00:00:00 GMT // + your cookie options
Deleting all sessions of the current user
The application should provide not only the sign out feature, but also the feature to sign out from all devices, if the user has any suspicions.
You can delete all sessions of the current user as follows:
app.post('/sign-out-from-all-devices', async (req, res) => {
// Redirect unauthenticated users to the sign in page
if (!req.session.id) {
res.redirect(302, '/sign-in');
return;
}
// Delete all sessions of the current user
await req.session.destroyAll();
res.redirect(302, '/sign-in');
});
Sessions associated with other users will not be affected.
You can also delete all sessions except the current one by passing the exceptCurrent
argument to the destroyAll
method like this:
await req.session.destroyAll(true); // exceptCurrent = true
Getting all sessions of the current user
The application can provide the user with a list of all their active sessions, as GitLab does.
You can get a list of all sessions of the current user as follows:
app.get('/active-sessions', async (req, res) => {
// Redirect unauthenticated users to the sign in page
if (!req.session.id) {
res.redirect(302, '/sign-in');
return;
}
// Get a list of all sessions of the current user
const list = await req.session.list();
// The list item contains all the session data, including you own
console.log(list[0].userId); // User ID (always the current one)
console.log(list[0].createdAt); // Timestamp when the session was created
console.log(list[0].regeneratedAt); // Timestamp when was the last time the session ID was regenerated (see renewalTimeout)
console.log(list[0].lastSeenAt); // Timestamp when was the last time the user made a request to the server
console.log(list[0].id); // The unique ID that can be used to delete this session (not equal to the real session ID)
console.log(list[0].current); // Whether the session is current
console.log(list[0].ip); // Your own field that should be set when creating or updating the session
console.log(list[0].userAgent); // Your own field that should be set when creating or updating the session
res.json(list);
});
The list of sessions is sorted by creation date in descending order.
The session data includes the following additional fields:
id
– used to delete the session.current
– indicates whether the session is current.
Using all of this data, you can show the user the following comprehensive information about each session:
- Device type (by
userAgent
). - Browser name (by
userAgent
). - Browser version (by
userAgent
). - Operating system (by
userAgent
). - IP address (by
ip
). - The country and city where the user is located (by
ip
). - When the user is signed in (by
createdAt
). - When was the user last active (by
lastSeenAt
). - Whether the session is current (by
current
).
You can also provide the user with a feature to delete a specific session that makes them suspicious using the destroy
method:
app.get('/delete-session', async (req, res) => {
// Redirect unauthenticated users to the sign in page
if (!req.session.id) {
res.redirect(302, '/sign-in');
return;
}
// Delete the specific session
const list = await req.session.destroy(req.body.sessionId);
res.send('The session has been deleted');
});
⚠️ The id
is not equal to the real session ID for security reasons.
It is generated based on the real session ID by the AES algorithm using Google's CryptoJS.
This algorithm uses a secret passphrase that you MUST specify in the middleware option as follows:
app.use(
session({
redis,
secret: 'secret',
})
);
Configuring timeouts
To minimize the chance that an attacker will hijack the session ID and to reduce the time during which an attacker will use it, you should use as shortest timeouts as possible.
Absolute timeout
Absolute timeout is the maximum number of seconds a session can be active since the given session was initially created.
👨💻 It specifies the amount of time an attacker can use a hijacked session ID.
By default, it is 31540000
(1 year), but you can change this value:
app.use(
session({
redis,
absoluteTimeout: 28800, // 8 hours
})
);
Idle timeout
Idle timeout is the number of seconds a session will remain active in case there is no activity in the session.
👨💻 It specifies the amount of time during which a thief of a user's device (mobile phone, laptop, or other device) should access to the application on behalf of the victim user (or just steal its session ID).
By default, it is 2592000
(30 days), but you can change this value:
app.use(
session({
redis,
idleTimeout: 300, // 5 minutes
})
);
To disable set 0.
Renewal timeout
Renewal timeout is the number of seconds after which the session is automatically renewed in the middle of the user session.
👨💻 It specifies the time during which an attacker must gain access to the application on behalf of the victim user after stealing its session ID. In addition, the combination of idle and renewal timeouts significantly complicates the execution of a session fixation attack.
By default, it is 1800
(30 minutes), but you can change this value:
app.use(
session({
redis,
renewalTimeout: 120, // 2 minutes
})
);
To disable set 0.
When the session ID has been renewed, the old session ID will be valid for some time, accommodating a safety interval, before the client is aware of the new session ID.
You can specify how long the old session ID will be valid using the deletionTimeout
middleware option.
Read more about timeouts in OWASP.
Setting the prefix for all Redis keys
Sometimes it is necessary to store sessions of different apps in the same Redis store. In this case, each app must have a unique prefix for all Redis keys. You can specify the prefix of your app as follows:
app.use(
session({
redis,
prefix: 'my-app',
})
);
Now the Redis keys will be my-app:session:key
and my-app:user:1:sessions
instead of session:key
and user:1:sessions
.