学生不再有登录能力:User.passwordHash 以空串占位,role=STUDENT 在密码校验之前 一律 403 并提示改用班级登录(否则「密码错 401 / 密码对但已停用 403」的差异会把 学生账号密码变成可离线验证的预言机)。成绩、未交名单、叫人、已读全部按名单记。 配套改动: - 删除 POST /students/:id/reset-password(调用返回 404,e2e 有负断言); - 新建学生不设密码,StudentsView 移除密码输入与「重置密码」入口, 列表标识仍自动生成为学号式(student01…); - class-account.ts / auth.service.ts 收紧班级会话语义; - 经管「修改密码」入口收敛到 Web 端:教师改本人密码走顶栏下拉, 班级密码由管理员在班级管理里维护,教师可用 POST /teachers/:id/reset-password 设初始密码。
138 lines
5.5 KiB
TypeScript
138 lines
5.5 KiB
TypeScript
import { randomInt } from 'node:crypto';
|
||
import { prisma } from './db.js';
|
||
import { ApiError } from './http.js';
|
||
import { hashPassword, verifyPassword } from './password.js';
|
||
|
||
/**
|
||
* 班级账号(学生端「班级」主体):
|
||
* - 每个班级有一个**班级码**(唯一、大写字母数字)和**班级密码**(bcrypt);
|
||
* - 学生端班级设备用「班级码 + 班级密码」登录,拿到 `classSession` 的 JWT;
|
||
* - 管理员可在「班级管理」里修改班级码、重置班级密码。
|
||
*
|
||
* 兼容性:个人学生账号(User.username)完全不受影响,仍然是独立账号。
|
||
*/
|
||
|
||
/** 班级码:去掉易混淆字符(0/O/1/I),4~16 位大写字母数字 */
|
||
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||
const CODE_PATTERN = /^[A-Za-z0-9]{4,16}$/;
|
||
|
||
/** 归一化班级码:去空格 + 转大写(登录时用户输入大小写都可) */
|
||
export function normalizeClassCode(input: string): string {
|
||
return input.trim().toUpperCase();
|
||
}
|
||
|
||
export function isValidClassCode(input: string): boolean {
|
||
return CODE_PATTERN.test(input.trim());
|
||
}
|
||
|
||
/** 随机生成一个未被占用的班级码 */
|
||
export async function generateClassCode(prefix = ''): Promise<string> {
|
||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||
const length = Math.max(4, 6 - prefix.length);
|
||
let body = '';
|
||
for (let index = 0; index < length; index += 1) {
|
||
// 用 CSPRNG 而不是 Math.random():班级码会作为学生端登录的账号名打印/张贴,
|
||
// 用可预测的 PRNG 生成等于把"半个凭证"送给猜码的人(randomInt 无取模偏差)
|
||
body += CODE_ALPHABET[randomInt(0, CODE_ALPHABET.length)];
|
||
}
|
||
const code = `${prefix}${body}`.toUpperCase();
|
||
const exists = await prisma.class.findUnique({ where: { code }, select: { id: true } });
|
||
if (!exists) return code;
|
||
}
|
||
throw new ApiError(500, 'INTERNAL_ERROR', '班级码生成失败,请重试或手动指定班级码');
|
||
}
|
||
|
||
/** 创建班级时调用:生成班级码并写入默认班级密码 */
|
||
export async function buildClassAccountCreateData(
|
||
defaultPassword: string,
|
||
requestedCode?: string,
|
||
): Promise<{ code: string; passwordHash: string }> {
|
||
const code = requestedCode ? normalizeClassCode(requestedCode) : await generateClassCode();
|
||
if (!isValidClassCode(code)) throw ApiError.badRequest('班级码需为 4~16 位字母或数字');
|
||
const taken = await prisma.class.findUnique({ where: { code }, select: { id: true } });
|
||
if (taken) throw ApiError.badRequest(`班级码 ${code} 已被占用,请更换`);
|
||
return { code, passwordHash: await hashPassword(defaultPassword) };
|
||
}
|
||
|
||
/** 管理员:修改班级码 / 重置班级密码 */
|
||
export async function updateClassAccount(
|
||
classId: string,
|
||
input: { code?: string; password?: string },
|
||
): Promise<{ code: string; hasPassword: boolean }> {
|
||
const current = await prisma.class.findUnique({ where: { id: classId }, select: { id: true, code: true } });
|
||
if (!current) throw ApiError.notFound('班级不存在');
|
||
|
||
const data: { code?: string; passwordHash?: string } = {};
|
||
|
||
if (input.code !== undefined) {
|
||
const code = normalizeClassCode(input.code);
|
||
if (!isValidClassCode(code)) throw ApiError.badRequest('班级码需为 4~16 位字母或数字');
|
||
if (code !== current.code) {
|
||
const taken = await prisma.class.findUnique({ where: { code }, select: { id: true } });
|
||
if (taken) throw ApiError.badRequest(`班级码 ${code} 已被其他班级使用`);
|
||
data.code = code;
|
||
}
|
||
}
|
||
|
||
if (input.password !== undefined && input.password !== '') {
|
||
if (input.password.length < 6) throw ApiError.badRequest('班级密码至少 6 位');
|
||
data.passwordHash = await hashPassword(input.password);
|
||
}
|
||
|
||
if (Object.keys(data).length === 0) {
|
||
throw ApiError.badRequest('没有需要更新的内容(班级码或班级密码)');
|
||
}
|
||
|
||
const updated = await prisma.class.update({
|
||
where: { id: classId },
|
||
data,
|
||
select: { code: true, passwordHash: true },
|
||
});
|
||
return { code: updated.code, hasPassword: Boolean(updated.passwordHash) };
|
||
}
|
||
|
||
/** 班级账号登录:返回班级记录(密码校验在 service 层完成) */
|
||
export async function findClassForLogin(code: string): Promise<{
|
||
id: string;
|
||
name: string;
|
||
grade: string;
|
||
code: string;
|
||
passwordHash: string | null;
|
||
}> {
|
||
const normalized = normalizeClassCode(code);
|
||
const record = await prisma.class.findUnique({
|
||
where: { code: normalized },
|
||
select: { id: true, name: true, grade: true, code: true, passwordHash: true },
|
||
});
|
||
if (!record) throw ApiError.unauthorized('班级码或班级密码错误');
|
||
if (!record.passwordHash) {
|
||
throw new ApiError(
|
||
403,
|
||
'CLASS_PASSWORD_NOT_SET',
|
||
'该班级尚未设置班级密码,请联系管理员在「班级管理」中设置',
|
||
);
|
||
}
|
||
return record;
|
||
}
|
||
|
||
export async function verifyClassPassword(plain: string, passwordHash: string): Promise<boolean> {
|
||
return verifyPassword(plain, passwordHash);
|
||
}
|
||
|
||
/** 班级账号自助修改班级密码 */
|
||
export async function changeClassPassword(
|
||
classId: string,
|
||
currentPassword: string,
|
||
newPassword: string,
|
||
): Promise<void> {
|
||
const record = await prisma.class.findUnique({ where: { id: classId }, select: { passwordHash: true } });
|
||
if (!record) throw ApiError.notFound('班级不存在');
|
||
if (record.passwordHash && !(await verifyPassword(currentPassword, record.passwordHash))) {
|
||
throw ApiError.badRequest('当前班级密码不正确');
|
||
}
|
||
await prisma.class.update({
|
||
where: { id: classId },
|
||
data: { passwordHash: await hashPassword(newPassword) },
|
||
});
|
||
}
|