2026-09-11 21:57:38 +08:00
|
|
|
|
import type { Request, RequestHandler } from 'express';
|
2026-10-11 11:49:32 +08:00
|
|
|
|
import type { PermissionKey, UserRole } from '@classhelper/shared';
|
|
|
|
|
|
import { PERMISSION_LABELS } from '@classhelper/shared';
|
2026-09-11 21:57:38 +08:00
|
|
|
|
import { prisma } from '../lib/db.js';
|
|
|
|
|
|
import { ApiError } from '../lib/http.js';
|
2026-10-11 11:49:32 +08:00
|
|
|
|
import { loadEffectivePermissions } from '../lib/permissions.js';
|
2026-09-11 21:57:38 +08:00
|
|
|
|
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 认证中间件:解析 Bearer token 并回查数据库,
|
2026-10-06 19:41:18 +08:00
|
|
|
|
* 保证角色 / 班级变更(例如管理员换了班主任)立即生效,不被过期载荷影响。
|
2026-10-11 11:49:32 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 2026-10-10 起**顺带装载生效权限集合**(`req.auth.permissions`):
|
|
|
|
|
|
* 角色判据换成权限键判据(`requirePermission`),角色只用来决定"没有分组时的默认模板"。
|
2026-09-11 21:57:38 +08:00
|
|
|
|
*/
|
|
|
|
|
|
export function authenticate(): RequestHandler {
|
|
|
|
|
|
return async (req, _res, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const token = extractBearerToken(req.headers.authorization);
|
|
|
|
|
|
if (!token) throw ApiError.unauthorized('缺少 Authorization 头');
|
|
|
|
|
|
|
|
|
|
|
|
const payload = verifyToken(token);
|
2026-09-12 15:51:08 +08:00
|
|
|
|
|
2026-10-06 19:41:18 +08:00
|
|
|
|
// ClassHelper 班级端:主体是**班级**而不是某个账号,回查 Class 表
|
2026-09-12 15:51:08 +08:00
|
|
|
|
if (payload.classSession) {
|
|
|
|
|
|
const record = await prisma.class.findUnique({
|
|
|
|
|
|
where: { id: payload.classId ?? payload.sub },
|
|
|
|
|
|
select: { id: true, name: true, code: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
|
|
|
|
|
|
|
2026-10-11 11:49:32 +08:00
|
|
|
|
const user: TokenPayload = {
|
|
|
|
|
|
sub: record.id,
|
|
|
|
|
|
username: record.code,
|
|
|
|
|
|
name: record.name,
|
|
|
|
|
|
role: 'CLASS_DEVICE',
|
|
|
|
|
|
classId: record.id,
|
|
|
|
|
|
classSession: true,
|
|
|
|
|
|
classCode: record.code,
|
2026-09-12 15:51:08 +08:00
|
|
|
|
};
|
2026-10-11 11:49:32 +08:00
|
|
|
|
req.auth = { token, user, permissions: await loadEffectivePermissions(user) };
|
2026-09-12 15:51:08 +08:00
|
|
|
|
next();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 19:41:18 +08:00
|
|
|
|
// 账号(教师 / 管理员):`User` 是纯账号表,学生不在里面
|
2026-09-11 21:57:38 +08:00
|
|
|
|
const user = await prisma.user.findUnique({
|
|
|
|
|
|
where: { id: payload.sub },
|
2026-10-06 19:41:18 +08:00
|
|
|
|
select: { id: true, username: true, name: true, role: true },
|
2026-09-11 21:57:38 +08:00
|
|
|
|
});
|
|
|
|
|
|
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
|
|
|
|
|
|
|
2026-10-11 11:49:32 +08:00
|
|
|
|
const authUser: TokenPayload = {
|
|
|
|
|
|
sub: user.id,
|
|
|
|
|
|
username: user.username,
|
|
|
|
|
|
name: user.name,
|
|
|
|
|
|
role: user.role as UserRole,
|
|
|
|
|
|
classId: null,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
};
|
2026-10-11 11:49:32 +08:00
|
|
|
|
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
|
2026-09-11 21:57:38 +08:00
|
|
|
|
next();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
next(error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 11:49:32 +08:00
|
|
|
|
/** 角色守卫(仍保留:少数场景要按角色而不是按权限键判断,如班级端专属入口) */
|
2026-09-11 21:57:38 +08:00
|
|
|
|
export function requireRole(...roles: UserRole[]): RequestHandler {
|
|
|
|
|
|
return (req, _res, next) => {
|
|
|
|
|
|
const user = req.auth?.user;
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
|
next(ApiError.unauthorized());
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!roles.includes(user.role)) {
|
|
|
|
|
|
next(ApiError.forbidden(`该操作仅限 ${roles.join(' / ')} 角色使用`));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
next();
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 11:49:32 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 权限守卫:要求当前登录主体拥有**全部**列出的权限键。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 权限集合来自 {@link loadEffectivePermissions}(权限组并集,或角色默认模板)。
|
|
|
|
|
|
* 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function requirePermission(...keys: PermissionKey[]): RequestHandler {
|
|
|
|
|
|
return (req, _res, next) => {
|
|
|
|
|
|
if (!req.auth?.user) {
|
|
|
|
|
|
next(ApiError.unauthorized());
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const permissions = req.auth.permissions ?? new Set<PermissionKey>();
|
|
|
|
|
|
const missing = keys.filter((key) => !permissions.has(key));
|
|
|
|
|
|
if (missing.length > 0) {
|
|
|
|
|
|
const labels = missing.map((key) => `「${PERMISSION_LABELS[key]}」`).join('');
|
|
|
|
|
|
next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
next();
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 权限守卫(任一满足):只要拥有其中**一个**权限键就放行。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用在"同一入口服务多种能力"的接口上:如导入模板按 `kind` 区分能力,
|
|
|
|
|
|
* 只要有一种导入能力就能取模板(具体哪种 kind 能不能导,由 service 里的 kind 断言决定)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler {
|
|
|
|
|
|
return (req, _res, next) => {
|
|
|
|
|
|
if (!req.auth?.user) {
|
|
|
|
|
|
next(ApiError.unauthorized());
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const permissions = req.auth.permissions ?? new Set<PermissionKey>();
|
|
|
|
|
|
if (keys.length > 0 && !keys.some((key) => permissions.has(key))) {
|
|
|
|
|
|
const labels = keys.map((key) => `「${PERMISSION_LABELS[key]}」`).join('、');
|
|
|
|
|
|
next(ApiError.forbidden(`当前账号缺少权限(${labels} 中至少需要一项),请联系管理员在「权限组」里分配`));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
next();
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 21:57:38 +08:00
|
|
|
|
/** 取出当前登录用户,未认证时抛 401 */
|
|
|
|
|
|
export function getAuthUser(req: Request): TokenPayload {
|
|
|
|
|
|
if (!req.auth?.user) throw ApiError.unauthorized();
|
|
|
|
|
|
return req.auth.user;
|
|
|
|
|
|
}
|
2026-10-11 11:49:32 +08:00
|
|
|
|
|
|
|
|
|
|
/** 取出当前登录主体的生效权限集合(未认证时抛 401) */
|
|
|
|
|
|
export function getAuthPermissions(req: Request): ReadonlySet<PermissionKey> {
|
|
|
|
|
|
if (!req.auth?.user) throw ApiError.unauthorized();
|
|
|
|
|
|
return req.auth.permissions ?? new Set<PermissionKey>();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 当前主体是否有某个权限键(页面/服务里做条件分支用,找不到也当无权限) */
|
|
|
|
|
|
export function hasAuthPermission(req: Request, key: PermissionKey): boolean {
|
|
|
|
|
|
return (req.auth?.permissions ?? new Set<PermissionKey>()).has(key);
|
|
|
|
|
|
}
|