从以前的会话中注销用户

Log user out of previous sessions

我有一个用户通过电子邮件注册的服务器。我想允许最多连接 N 台设备,例如计算机、phone 和平板电脑。我想阻止用户与许多其他用户共享凭据,因此我想在用户登录时注销除 N 最近的所有会话。

我正在使用 NodeJS、MongoDB 和带有自定义一次性密码 (otp) 身份验证策略的 Passport:

用户模型文件包括:

const mongoose = require('mongoose');

const UserSchema = new Schema({
  // ...
});

UserSchema.methods.validateOtp = async function(otp) {
  // ...
};

用户的路由文件包括:

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...
  }
));

我找到了 NodeJS logout all user sessions,但我无法在数据库中找到 sessions 集合,尽管我有两个活动会话。

我如何才能让用户退出 N 最近的会话?

更新

回答后,我意识到我遗漏了与会话相关的代码。主脚本文件包括:

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'));

模块 cookie-session 将数据存储在客户端上,我认为它无法处理除最后 N 会话之外的所有注销,因为服务器上没有数据库。

您确定您目前确实拥有持久会话存储吗?如果您没有故意在 post 中遗漏任何中间件,那么我怀疑您没有。

大多数使用 express 的开发的首选是 express-session,它需要作为自己的中间件添加。在其默认配置中,express-session 只会将所有会话存储在内存中。内存存储不会通过重新启动持久存在,并且除了存储会话信息之外,不容易与其他任何目的进行交互。 (如用户查询会话以删除它们)

我怀疑您想要使用 connect-mongodb-session 作为 express-session 的会话存储机制。这会将您的会话存储在 mongodb 中的 'sessions' 集合中。这里有一些样板可以帮助您。

请原谅此处可能存在的任何小错误,我在此处编写所有这些代码时没有 运行 任何一个,因此可能存在您需要更正的小问题。

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

因此,在您让 cookie 和会话正常工作之后,下一步就是让路由实际受您的身份验证保护。我们正在设置它,以便我们确定一切正常。

// 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!');
});

在这一步你应该检查如果你没有登录你不能到达秘密路线(应该得到错误),如果你登录你可以到达它(见秘密消息)。注销与往常一样:req.logout() 在您的注销路径中。假设现在一切顺利,让我们解决实际问题,注销除最近 4 个会话之外的所有内容。

现在,为简单起见,我假设您对每个用户强制执行 otp。因此,我们可以利用您之前声明的护照 otp 中间件。如果你不是,那么你可能需要对护照做更多的自定义逻辑。

// 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
  }
));

现在,如果您从不同的设备登录 4 次,或者每次都清除 cookie 后,您应该能够在 mongo 控制台中查询并查看所有 4 个会话。如果您第 5 次登录,您应该会看到仍然只有 4 个会话,并且最旧的已被删除。

我要再说一次,我实际上并没有尝试执行我在这里编写的任何代码,所以我可能遗漏了一些小东西或包含错别字。请花点时间尝试自己解决任何问题,但如果仍然无法解决问题,请告诉我。

留给你的任务:

  • 如果您不将 session.passport.user 的索引添加到 sessions 集合,您的 mongo 查询性能将低于标准。您应该为该字段添加索引,例如运行 db.sessions.createIndex({"session.passport.user": 1}) 在 Mongo shell 上(参见 docs)。 (注意:虽然 passportsession 字段的子文档,但您可以像访问 Javascript 对象一样访问它:session.passport。)
  • 如果重设密码,您可能还应该注销其他会话 已执行。
  • 您应该在调用 req.logout() 时从集合中删除会话。
  • 为了对用户友好,您可以向已撤销的会话添加一条消息,以便在用户尝试从以前的设备访问内容时显示。过期会话也是如此。您可以删除这些会话以保持集合较小。
  • 即使没有登录,模块 express-session 也会在用户的浏览器中存储一个 cookie。为了符合欧洲的 GDPR,您应该添加有关 cookie 的通知。
  • 实施从 cookie-session(存储在客户端中)到 express-session 的更改将注销所有以前的用户。为了对用户友好,您应该提前警告他们并确保您一次完成所有更改,而不是尝试多次,而他们因必须多次登录而感到恼火。