外观
鉴权系统
概述
系统维护两套独立的鉴权方案,登录时同时创建两种凭证:
- Session 鉴权:基于 Cookie + Redis 的服务端 session,适用于浏览器端 SSR 页面
- Token 鉴权:基于 JWT 的 Bearer Token,适用于 API 客户端
两套鉴权共享一个 Redis Sorted Set user:logins:{userId} 来维护用户同时登录数量限制(MAX_USER_SESSIONS,默认 3)。
text
Redis Sorted Set: user:logins:{userId}
member: "session:{sessionID}" → score: 登录时间戳 (ms)
member: "token:{jti}" → score: 登录时间戳 (ms)1
2
3
2
3
1. Session 鉴权
1.1 中间件配置
SessionMiddleware.mts 配置 express-session 使用 connect-redis 的 RedisStore 存储 session 数据:
typescript
// src/middlewares/SessionMiddleware.mts
export const sessionMiddleware = session({
secret: JWT_SECRET_KEY,
resave: false, // session 未修改时不回写
saveUninitialized: false, // 不自动创建空 session
name: "session_id", // cookie 名称
store: new RedisStore({
client: getRedisClient(),
prefix: "session:", // Redis key 前缀
}),
cookie: {
path: "/",
httpOnly: true, // 禁止 JS 访问,防 XSS
secure: false, // 是否启用 HTTPS
maxAge: SESSION_EXPIRE_MINUTES * 60 * 1000, // 过期时间
sameSite: "lax", // 防 CSRF
},
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Redis 存储:connect-redis 自动为每个 session key 设置 TTL(等于 maxAge),到期自动删除。
1.2 数据创建(登录)
用户登录成功后,prepareTokenAndSessionData 在 services/user.mts 中执行:
typescript
// 设置 session 数据(express-session 自动写入 RedisStore)
req.session.userId = userId;
req.session.loginTime = new Date().toISOString();
req.session.userAgent = req.get("User-Agent");
req.session.ip = req.realIp;
// 将 session 加入活跃登录集
await manageUserSessions({ userId, loginMember: `session:${req.sessionID}` });1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
触发时机:controllers/user.mts 中的 login、loginByPhone、register,以及 controllers/oauth.mts 中的 GitHub OAuth 回调。
1.3 数据传输
Session ID 通过名为 session_id 的 HTTP-only Cookie 在浏览器和服务端之间自动传递,无需前端手动处理。
1.4 数据验证(UserMiddleware session 路径)
UserMiddleware.mts 在每次请求时执行鉴权,session 路径的完整流程:
text
请求到达 → SessionMiddleware 解析 session_id cookie → 从 Redis 加载 session 数据
→ req.session.userId 存在?
├── 否 → req.user = undefined(未登录)
└── 是 → 检查 Redis Sorted Set:
redisZIsMember("user:logins:{userId}", "session:{sessionID}")
├── 不存在 → 该 session 已被裁剪(超出 MAX_USER_SESSIONS)
│ → req.user = undefined
└── 存在 → 从 MySQL 查询用户信息
→ 校验 IP 变更(仅 warn)
→ req.user = userInfo(已登录)1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
1.5 同时登录数量限制
manageUserSessions 在 services/user.mts 中维护登录数量:
typescript
export const manageUserSessions = async ({ userId, loginMember }) => {
const key = `user:logins:${userId}`;
const now = Date.now();
// 1. 添加当前登录到 Sorted Set
await redisZAdd(key, now, loginMember);
// 2. 检查是否超过 MAX_USER_SESSIONS
const count = await redisZCount(key);
if (count > MAX_USER_SESSIONS) {
// 3. 删除最早的 (count - MAX_USER_SESSIONS) 个
await redisZRemRangeByRank(key, 0, count - MAX_USER_SESSIONS - 1);
}
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
淘汰机制:每次登录时,Redis Sorted Set 按时间戳排序,超出 MAX_USER_SESSIONS 的旧记录被自动裁剪。被裁剪的 session 在下次请求验证时因 redisZIsMember 返回 false 而失效。
1.6 数据销毁(退出登录)
SSR 退出(controllers/root.mts cLogout):
typescript
// 1. 从 Sorted Set 移除当前 session(不影响其他设备)
await redisZRem(`user:logins:${userId}`, `session:${req.sessionID}`);
// 2. 销毁 session(connect-redis 自动删除 Redis key)
await destroyReqSessionData(req);
// 3. 清除 cookie
res.clearCookie("session_id", { path: "/" });1
2
3
4
5
6
2
3
4
5
6
API 退出(controllers/user.mts logout)同时处理 session 和 token 两条记录:
typescript
await redisZRem(`user:logins:${userId}`, `session:${req.sessionID}`);
await redisZRem(`user:logins:${userId}`, `token:${jti}`);
await destroySession({ req });1
2
3
2
3
注意:退出登录只移除当前设备的登录记录,不影响其他设备的登录状态。
2. Token 鉴权
2.1 数据创建(登录)
登录时,prepareTokenAndSessionData 通过 generateJwtToken 生成 JWT:
typescript
// src/utils/JWTUtils.mts
export function generateJwtToken(payload: { userId: number }): string {
const seconds = SESSION_EXPIRE_MINUTES * 60;
return jwt.sign(
{ ...payload, jti: randomUUID() }, // jti 为唯一标识
JWT_SECRET_KEY,
{ expiresIn: `${seconds}s` },
);
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
JWT Payload 结构:
json
{
"userId": 1,
"jti": "550e8400-e29b-41d4-a716-446655440000",
"iat": 1718000000,
"exp": 1718003600
}1
2
3
4
5
6
2
3
4
5
6
jti(JWT ID)是每次登录生成的唯一标识,用于在 Redis Sorted Set 中追踪该 token 的活跃状态。
Token 生成后同样调用 manageUserSessions 加入 Sorted Set:
typescript
await manageUserSessions({ userId, loginMember: `token:${jti}` });1
2.2 数据传输
客户端需要在请求头中携带 Token:
http
Authorization: Bearer <token>1
2.3 数据验证(UserMiddleware token 路径)
UserMiddleware.mts 中,当 req.session.userId 不存在时,进入 token 鉴权路径:
text
请求到达 → req.session.userId 不存在(无 session 登录)
→ 检查 Authorization header
├── 无 → req.user = undefined(未登录)
└── 有 Bearer token → verifyToken 解码 JWT
├── 解码失败 → 记录安全日志 → req.user = undefined
└── 解码成功 → 获取 { userId, jti }
→ 检查 Redis Sorted Set:
redisZIsMember("user:logins:{userId}", "token:{jti}")
├── 不存在 → token 已被裁剪 → req.user = undefined
└── 存在 → 查询用户信息
→ 校验 IP 变更(不匹配则拒登)
→ req.user = userInfo(已登录)1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
2.4 同时登录数量限制
Token 与 session 共享同一个 user:logins:{userId} Sorted Set。MAX_USER_SESSIONS 限制的是 session + token 的总数。例如,当 MAX_USER_SESSIONS=3 时,一个用户最多可以同时有 2 个 session 登录 + 1 个 token 登录,或者 3 个 token 登录。
2.5 数据销毁
API 退出(controllers/user.mts logout)通过解码请求头中的 token 获取 jti,从 Sorted Set 中移除对应记录:
typescript
const authHeader = req.get("Authorization");
const token = authHeader && authHeader.split(" ")[1];
if (token) {
const [, dataToken] = verifyToken(token);
if (dataToken) {
await redisZRem(`user:logins:${userId}`, `token:${dataToken.jti}`);
}
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
注意:Token 本身是无状态的 JWT,服务端无法主动使其失效。退出登录仅移除 Sorted Set 中的记录,使该 token 在下次请求时因
redisZIsMember返回false而被拒绝。JWT 自身的exp过期时间由jsonwebtoken库在验证时保证。
3. 核心数据结构汇总
Redis Key 一览
| Key 模式 | 类型 | 用途 | 生命周期 |
|---|---|---|---|
session:{sessionID} | String | Session 数据(由 connect-redis 管理) | TTL = SESSION_EXPIRE_MINUTES |
user:logins:{userId} | Sorted Set | 活跃登录记录 | 持续累积,登录时自动裁剪 |
配置项
| 变量 | 默认值 | 说明 |
|---|---|---|
SESSION_EXPIRE_MINUTES | - | Session 和 JWT 的过期时间(分钟) |
MAX_USER_SESSIONS | 3 | 允许的最大同时登录数量 |
JWT_SECRET_KEY | - | JWT 签名密钥 |
4. 鉴权流程图
text
┌─────────────────────────────────────────────────────────┐
│ 请求到达 │
│ │ │
│ SessionMiddleware │
│ 解析 session_id cookie,加载 session │
│ │ │
│ UserMiddleware │
│ │ │
│ ┌──────────┴──────────┐ │
│ ▼ ▼ │
│ req.session.userId req.session.userId │
│ 存在(Session 鉴权) 不存在 │
│ │ │ │
│ ▼ ▼ │
│ redisZIsMember Authorization header │
│ 检查 session 活跃 ├── 无 → req.user=undefined │
│ ├── 不存在 → 已淘汰 └── 有 → verifyToken │
│ └── 存在 → 查用户信息 │ │
│ ▼ │
│ redisZIsMember │
│ 检查 token 活跃 │
│ ├── 不存在 → 已淘汰 │
│ └── 存在 → 查用户信息 │
│ │ │
│ ┌────────────┘ │
│ ▼ │
│ req.user = userInfo │
│ route handler 使用 req.user │
└─────────────────────────────────────────────────────────┘1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29