I have a server where users sign up by email. I want to allow connection in at most N
devices, such as computer, phone and tablet. I want to discourage a user sharing credentials with many others, and so I want to logout all but the N
most recent sessions when a user logs in.
I am using NodeJS, MongoDB, and Passport with a custom one-time password (otp
) authentication strategy:
The user model file includes:
const mongoose = require('mongoose');
const UserSchema = new Schema({
// ...
});
UserSchema.methods.validateOtp = async function(otp) {
// ...
};
The users' routes file includes:
const express = require('express');
const router = express.Router();
const passport = require('passport');
router.post(
"/login",
passport.authenticate("user-otp", {
successRedirect: "/dashboard",
failureRedirect: "back",
})
);
passport.use('user-otp', new CustomStrategy(
async function(req, done) {
user = await User.findOne({req.body.email});
let check = await user.validateOtp(req.body.otp);
// more logic...
}
));
I found NodeJS logout all user sessions but I could not find the sessions
collection in the database, even though I have two active sessions on it.
How can I log the user out of all but the N
most recent sessions?
After the answer, I realize I left out code related to the session. The main script file includes:
const cookieParser = require('cookie-parser');
const passport = require('passport');
const session = require('cookie-session');
app.use(cookieParser("something secret"));
app.use(
session({
// cookie expiration: 90 days
maxAge: 90 * 24 * 60 * 60 * 1000,
secret: config.secret,
signed: true,
resave: true,
httpOnly: true, // Don't let browser javascript access cookies.
secure: true, // Only use cookies over https.
})
);
app.use(passport.initialize());
app.use(passport.session());
app.use('/', require('./routes/users'));
The module cookie-session
stores data on the client and I don't think it can handle logging out all but the last N
sessions, since there is no database on the server.
Are you sure you actually have a persistent session store currently? If you are not intentionally leaving out any middleware in your post then I suspect you do not.
The go-to for most development using express is express-session
which needs to be added as its own middleware. In its default configuration, express-session will just store all sessions in memory though. Memory storage is not persistent through restarts and is not easy to interact with for any purpose other than storing session information. (like querying sessions by user to delete them)
I suspect what you will want to use is connect-mongodb-session
as a session storage mechanism for express-session
. This will store your sessions in mongodb in a 'sessions' collection. Here's some boilerplate to help you along.
Please excuse any minor bugs that may exist here, I am writing all of this code here without running any of it, so there could be small issues you need to correct.
const express = require('express');
const passport = require('passport');
const session = require('express-session');
const MongoDBStore = require('connect-mongodb-session')(session);
const app = express();
const router = express.Router();
// Initialize mongodb session storage
const store = new MongoDBStore({
uri: 'mongodb://localhost:27017/myDatabaseName',
// The 'expires' option specifies how long after the last time this session was used should the session be deleted.
// Effectively this logs out inactive users without really notifying the user. The next time they attempt to
// perform an authenticated action they will get an error. This is currently set to 1 hour (in milliseconds).
// What you ultimately want to set this to will be dependent on what your application actually does.
// Banks might use a 15 minute session, while something like social media might be a full month.
expires: 1000 * 60 * 60,
});
// Initialize and insert session middleware into the app using mongodb session storage
app.use(session({
secret: 'This is a secret that you should securely generate yourself',
cookie: {
// Specifies how long the user's browser should keep their cookie, probably should match session expires
maxAge: 1000 * 60 * 60
},
store: store,
// Boilerplate options, see:
// * https://www.npmjs.com/package/express-session#resave
// * https://www.npmjs.com/package/express-session#saveuninitialized
resave: true,
saveUninitialized: true
}));
// Probably should include any body parser middleware here
app.use(passport.initialize());
app.use(passport.session());
// Should init passport stuff here like your otp strategy
// Routes go here
So after you get cookies and sessions working, the next part is to have routes which are actually protected by your authentication. We're setting this up so that we know for sure that everything is working.
// Middleware to reject users who are not logged in
var isAuthenticated = function(req, res, next) {
if (req.user) {
return next();
}
// Do whatever you want to happen when the user is not logged in, could redirect them to login
// Here's an example of just rejecting them outright
return res.status(401).json({
error: 'Unauthorized'
});
}
// Middleware added to this route makes it protected
router.get('/mySecretRoute', isAuthenticated, (req, res) => {
return res.send('You can only see this if you are logged in!');
});
At this step you should check that if you are not logged in that you can't reach the secret route (should get error), and if you are logged in you can reach it (see the secret message). Logging out is the same as usual: req.logout()
in your logout route. Assuming all is well now let's attack the actual issue, logging out everything except the 4 most recent sessions.
Now, for simplicity, I'm going to assume you are enforcing otp on every user. Because of this we can take advantage of the passport otp middleware you declared earlier. If you aren't then you may need do a bit more custom logic with passport.
// Connect to the database to access the `sessions` collection.
// No need to share the connection from the main script `app.js`,
// since you can have multiple connections open to mongodb.
const mongoose = require('mongoose');
const connectRetry = function() {
mongoose.connect('mongodb://localhost:27017/myDatabaseName', {
useUnifiedTopology: true,
useNewUrlParser: true,
useCreateIndex: true,
poolSize: 500,
}, (err) => {
if (err) {
console.error("Mongoose connection error:", err);
setTimeout(connectRetry, 5000);
}
});
}
connectRetry();
passport.use('user-otp', new CustomStrategy(
async function(req, done) {
user = await User.findOne({ req.body.email });
let check = await user.validateOtp(req.body.otp);
// Assuming your logic has decided this user can login
// Query for the sessions using raw mongodb since there's no mongoose model
// This will query for all sessions which have 'session.passport.user' set to the same userid as our current login
// It will ignore the current session id
// It will sort the results by most recently used
// It will skip the first 3 sessions it finds (since this session + 3 existing = 4 total valid sessions)
// It will return only the ids of the found session objects
let existingSessions = await mongoose.connection.db.collection('sessions').find({
'session.passport.user': user._id.toString(),
_id: {
$ne: req.session._id
}
}).sort({ expires: 1}).skip(3).project({ _id: 1 }).toArray();
// Note: .toArray() is necessary to convert the native Mongoose Cursor to an array.
if (existingSessions.length) {
// Anything we found is a session which should be destroyed
await mongoose.connection.db.collection('sessions').deleteMany({
_id: {
$in: existingSessions.map(({ _id }) => _id)
}
});
}
// Done with revoking old sessions, can do more logic or return done
}
));
Now if you login 4 times from different devices, or after clearing cookies each time, you should be able to query in your mongo console and see all 4 sessions. If you login a 5th time, you should see that there are still only 4 sessions and that the oldest was deleted.
Again I'll mention I haven't actually tried to execute any of the code I've written here so I may have missed small things or included typos. Please take a second and try to resolve any issues yourself, but if something still doesn't work let me know.
Tasks left to you:
session.passport.user
to the sessions
collection. You should add an index for that field, e.g. run db.sessions.createIndex({"session.passport.user": 1})
on the Mongo shell (see docs). (Note: although passport
is a sub-document of the session
field, you access it like a Javascript object: session.passport
.)req.logout()
.express-session
stores a cookie in the user's browser even without logging in. To be compliant with GDPR in Europe, you should add a notice about cookies.cookie-session
(stored in the client) to express-session
will log out all previous users. To be friendly to the user, you should warn them ahead of time and make sure you make all the changes at once, instead of trying multiple times and them getting exasperated at having to log in multiple times.