You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何基于Express、Prisma、PostgreSQL实现REST API单设备登录限制?

实现同一用户单设备登录限制的安全方案(Express + Prisma + PostgreSQL)

一、数据库模型设计

先在Prisma Schema中定义User和Session表,用Session追踪用户的登录会话状态:

model User {
  id           String    @id @default(cuid())
  email        String    @unique
  passwordHash String
  sessions     Session[]
  // 其他用户字段(如昵称、创建时间等)
}

model Session {
  id           String   @id @default(cuid())
  userId       String
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  deviceId     String   // 设备唯一标识(客户端生成)
  tokenHash    String   // 会话令牌的哈希值(禁止存明文)
  expiresAt    DateTime // 会话过期时间
  createdAt    DateTime @default(now())
  lastActiveAt DateTime @default(now())

  // 复合唯一索引:同一用户同一设备仅能存在一个有效会话
  @@unique([userId, deviceId])
}

deviceId建议由客户端生成,用UUID结合设备非敏感信息(如系统版本、应用版本的哈希值)生成,避免收集IMEI、MAC地址等隐私数据;tokenHash用bcrypt或Argon2加密存储,防止令牌泄露后被直接盗用。

二、核心登录逻辑

登录时执行以下步骤,确保用户未达到设备登录上限:

1. 验证用户凭证

常规流程:用bcrypt比对用户提交的密码与数据库中存储的passwordHash,验证通过后进入下一步。

2. 检查有效会话数量

查询用户当前未过期的会话数量,若已达到设定上限(比如1,对应“阻止其他设备登录”的需求),直接返回错误:

// Express登录路由示例
const MAX_ALLOWED_SESSIONS = 1;

// 验证用户凭证后执行
const activeSessionCount = await prisma.session.count({
  where: {
    userId: user.id,
    expiresAt: { gt: new Date() }
  }
});

if (activeSessionCount >= MAX_ALLOWED_SESSIONS) {
  return res.status(403).json({ message: "当前账号已在其他设备登录,无法在新设备登录" });
}

3. 创建新会话

若会话数量未超限,生成安全的会话令牌,哈希后存入数据库,同时返回令牌给客户端:

const crypto = require('crypto');
const bcrypt = require('bcrypt');

// 生成32字节的随机会话令牌
const sessionToken = crypto.randomBytes(32).toString('hex');
const tokenHash = await bcrypt.hash(sessionToken, 10);
const deviceId = req.body.deviceId; // 客户端传入的设备唯一标识

await prisma.session.create({
  data: {
    userId: user.id,
    deviceId,
    tokenHash,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 设置7天过期
  }
});

res.json({ token: sessionToken, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) });

三、会话验证与维护

1. 接口鉴权中间件

所有需要登录的接口通过中间件验证会话令牌的有效性:

async function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ message: "未提供有效登录令牌" });
  }

  const token = authHeader.split(' ')[1];
  // 先查询用户所有未过期的会话
  const userSessions = await prisma.session.findMany({
    where: {
      expiresAt: { gt: new Date() }
    }
  });

  // 比对令牌哈希,找到有效会话
  const validSession = userSessions.find(session => bcrypt.compareSync(token, session.tokenHash));
  if (!validSession) {
    return res.status(401).json({ message: "令牌无效或已过期" });
  }

  // 更新会话最后活跃时间
  await prisma.session.update({
    where: { id: validSession.id },
    data: { lastActiveAt: new Date() }
  });

  req.user = await prisma.user.findUnique({ where: { id: validSession.userId } });
  next();
}

2. 过期会话清理

用定时任务定期清理数据库中的过期会话,避免冗余数据:

const schedule = require('node-schedule');

// 每天凌晨2点执行清理
schedule.scheduleJob('0 2 * * *', async () => {
  await prisma.session.deleteMany({
    where: { expiresAt: { lt: new Date() } }
  });
});

四、安全增强措施

  • 令牌存储安全:客户端需将会话令牌存在安全位置(iOS用Keychain、Android用加密的SharedPreferences),禁止存在localStorage防止XSS窃取。
  • 强制HTTPS:所有API请求必须通过HTTPS传输,防止令牌被中间人劫持。
  • 注销功能:提供注销接口,删除当前会话,允许用户主动退出后在其他设备登录:
app.post('/api/logout', authenticate, async (req, res) => {
  const token = req.headers.authorization.split(' ')[1];
  const userSessions = await prisma.session.findMany({
    where: { userId: req.user.id, expiresAt: { gt: new Date() } }
  });
  const sessionToDelete = userSessions.find(s => bcrypt.compareSync(token, s.tokenHash));
  
  if (sessionToDelete) {
    await prisma.session.delete({ where: { id: sessionToDelete.id } });
  }

  res.json({ message: "注销成功" });
});

五、可选优化:设备管理功能

若后续需要支持用户查看已登录设备并主动踢除,可在Session表中新增deviceName字段(客户端传入设备名称,如“iPhone 14”),然后提供接口返回用户所有有效会话,用户选择删除指定会话即可实现踢设备功能。

内容的提问来源于stack exchange,提问作者fueripe-desu

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.22 01:09:28