如何基于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
相关产品推荐
相关产品推荐

