1
0
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2025-09-03 16:19:24 +00:00

Add bcrypt and js-base64 dependencies in package.json and pnpm-lock.yaml. Enhance authentication middleware in auth.js with password hint functionality and improve error handling. Update device management routes in kv.js to support password hint retrieval and modification, ensuring better security and user experience.

This commit is contained in:
SunWuyuan 2025-05-18 14:33:16 +08:00
parent b312756d5f
commit cf646d619f
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
8 changed files with 2419 additions and 36 deletions

View File

@ -1,6 +1,8 @@
import { siteKey } from "../config.js";
import AppError from "../utils/errors.js";
import { PrismaClient } from "@prisma/client";
import { DecodeAndVerifyPassword, verifySiteKey } from "../utils/crypto.js";
const prisma = new PrismaClient();
export const ACCESS_TYPES = {
@ -16,7 +18,8 @@ export const checkSiteKey = (req, res, next) => {
const providedKey =
req.headers["x-site-key"] || req.query.sitekey || req.body?.sitekey;
if (!providedKey || providedKey !== siteKey) {
if (!verifySiteKey(providedKey, siteKey)) {
return res.status(401).json({
statusCode: 401,
message: "此服务器已开启站点密钥验证,请提供有效的站点密钥",
@ -42,7 +45,6 @@ async function getOrCreateDevice(uuid, className) {
},
});
} catch (error) {
// 如果是唯一约束错误(并发创建),重新获取设备
if (error.code === "P2002") {
device = await prisma.device.findUnique({
where: { uuid },
@ -53,7 +55,6 @@ async function getOrCreateDevice(uuid, className) {
}
}
// 如果设备没有密码自动设为public
if (
device &&
!device.password &&
@ -72,6 +73,22 @@ async function getOrCreateDevice(uuid, className) {
}
}
export const deviceInfoMiddleware = async (req, res, next) => {
const { namespace } = req.params;
try {
const device = await getOrCreateDevice(namespace, req.body?.className);
res.locals.device = device;
next();
} catch (error) {
console.error("Auth middleware error:", error);
res.status(500).json({
statusCode: 500,
message: "服务器内部错误",
});
}
};
export const authMiddleware = async (req, res, next) => {
const { namespace } = req.params;
const password =
@ -81,7 +98,7 @@ export const authMiddleware = async (req, res, next) => {
try {
const device = await getOrCreateDevice(namespace, req.body?.className);
req.device = device;
res.locals.device = device;
if (device.password && password !== device.password) {
return res.status(401).json({
@ -111,13 +128,16 @@ export const readAuthMiddleware = async (req, res, next) => {
const device = await getOrCreateDevice(namespace);
res.locals.device = device;
// PUBLIC and PROTECTED devices are always readable
if ([ACCESS_TYPES.PUBLIC, ACCESS_TYPES.PROTECTED].includes(device.accessType)) {
if (
[ACCESS_TYPES.PUBLIC, ACCESS_TYPES.PROTECTED].includes(device.accessType)
) {
return next();
}
// For PRIVATE devices, require password
if (!device.password || password !== device.password) {
if (
!device.password ||
!(await DecodeAndVerifyPassword(password, device.password))
) {
return res.status(401).json({
statusCode: 401,
message: "设备密码验证失败",
@ -145,13 +165,14 @@ export const writeAuthMiddleware = async (req, res, next) => {
const device = await getOrCreateDevice(namespace);
res.locals.device = device;
// PUBLIC devices are always writable
if (device.accessType === ACCESS_TYPES.PUBLIC) {
return next();
}
// For PROTECTED and PRIVATE devices, require password
if (!device.password || password !== device.password) {
if (
!device.password ||
!(await DecodeAndVerifyPassword(password, device.password))
) {
return res.status(401).json({
statusCode: 401,
message: "设备密码验证失败",
@ -179,38 +200,34 @@ export const removePasswordMiddleware = async (req, res, next) => {
try {
const device = await getOrCreateDevice(namespace);
req.device = device;
res.locals.device = device;
// 验证站点令牌(如果设置了的话)
if (siteKey && (!providedKey || providedKey !== siteKey)) {
if (!verifySiteKey(providedKey, siteKey)) {
return res.status(401).json({
statusCode: 401,
message: "此服务器已开启站点密钥验证,请提供有效的站点密钥",
});
}
// 验证设备密码
if (device.password) {
if (!password || password !== device.password) {
return res.status(401).json({
statusCode: 401,
message: "设备密码验证失败",
});
}
} else {
return res.status(400).json({
statusCode: 400,
message: "设备当前没有设置密码",
if (
device.password &&
!(await DecodeAndVerifyPassword(password, device.password))
) {
return res.status(401).json({
statusCode: 401,
message: "设备密码验证失败",
});
}
// 更新设备,移除密码
await prisma.device.update({
where: { uuid: namespace },
data: { password: null },
where: { uuid: device.uuid },
data: {
password: null,
accessType: ACCESS_TYPES.PUBLIC,
},
});
res.json({ message: "密码已成功移除" });
next();
} catch (error) {
console.error("Remove password middleware error:", error);
res.status(500).json({

View File

@ -5,6 +5,7 @@ const errorHandler = (err, req, res, next) => {
if (res.headersSent) {
return next(err);
}
console.error(err);
try {
if (isDevelopment) {

View File

@ -23,6 +23,7 @@
"@opentelemetry/semantic-conventions": "^1.33.0",
"@prisma/client": "6.8.2",
"axios": "^1.9.0",
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
"cookie-parser": "~1.4.4",
"cors": "^2.8.5",
@ -32,6 +33,7 @@
"express": "~5.1.0",
"express-rate-limit": "^7.5.0",
"http-errors": "~2.0.0",
"js-base64": "^3.7.7",
"morgan": "~1.10.0",
"uuid": "^11.1.0"
},

32
pnpm-lock.yaml generated
View File

@ -32,6 +32,9 @@ importers:
axios:
specifier: ^1.9.0
version: 1.9.0(debug@4.4.0)
bcrypt:
specifier: ^6.0.0
version: 6.0.0
body-parser:
specifier: ^2.2.0
version: 2.2.0
@ -59,6 +62,9 @@ importers:
http-errors:
specifier: ~2.0.0
version: 2.0.0
js-base64:
specifier: ^3.7.7
version: 3.7.7
morgan:
specifier: ~1.10.0
version: 1.10.0
@ -671,6 +677,10 @@ packages:
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
engines: {node: '>= 0.8'}
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
bignumber.js@9.3.0:
resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==}
@ -964,6 +974,9 @@ packages:
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
hasBin: true
js-base64@3.7.7:
resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==}
json-bigint@1.0.0:
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
@ -1025,6 +1038,10 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
node-addon-api@8.3.1:
resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==}
engines: {node: ^18 || ^20 || >= 21}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@ -1034,6 +1051,10 @@ packages:
encoding:
optional: true
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@ -2082,6 +2103,11 @@ snapshots:
dependencies:
safe-buffer: 5.1.2
bcrypt@6.0.0:
dependencies:
node-addon-api: 8.3.1
node-gyp-build: 4.8.4
bignumber.js@9.3.0: {}
body-parser@2.2.0:
@ -2396,6 +2422,8 @@ snapshots:
jiti@2.4.2: {}
js-base64@3.7.7: {}
json-bigint@1.0.0:
dependencies:
bignumber.js: 9.3.0
@ -2448,10 +2476,14 @@ snapshots:
negotiator@1.0.0: {}
node-addon-api@8.3.1: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-gyp-build@4.8.4: {}
object-assign@4.1.1: {}
object-inspect@1.13.3: {}

View File

@ -27,6 +27,7 @@ model KVStore {
model Device {
uuid String @id @db.Char(36)
password String?
passwordHint String?
name String?
accessType AccessType @default(PUBLIC)
createdAt DateTime @default(now())

View File

@ -9,7 +9,15 @@ import {
readAuthMiddleware,
writeAuthMiddleware,
removePasswordMiddleware,
authMiddleware,
deviceInfoMiddleware
} from "../middleware/auth.js";
import {
DecodeAndhashPassword,
DecodeAndVerifyPassword,
hashPassword,
verifyPassword,
} from "../utils/crypto.js";
const prisma = new PrismaClient();
@ -55,7 +63,7 @@ router.get(
router.get(
"/:namespace/_check",
checkRestrictedUUID,
writeAuthMiddleware,
deviceInfoMiddleware,
errors.catchAsync(async (req, res) => {
const device = res.locals.device;
if (!device) {
@ -65,7 +73,7 @@ router.get(
});
}
res.json({
status: 'success',
status: "success",
uuid: device.uuid,
name: device.name,
accessType: device.accessType,
@ -74,22 +82,112 @@ router.get(
})
);
// Get device info
router.post(
"/:namespace/_checkpassword",
checkRestrictedUUID,
deviceInfoMiddleware,
errors.catchAsync(async (req, res) => {
const { password } = req.body;
const device = res.locals.device;
if (!device) {
return res.status(404).json({
statusCode: 404,
message: "设备不存在",
});
}
const isPasswordValid = await verifyPassword(password, device.password);
if (!isPasswordValid) {
return res.status(401).json({
statusCode: 401,
message: "密码错误",
});
}
res.json({
status: "success",
uuid: device.uuid,
name: device.name,
accessType: device.accessType,
hasPassword: !!device.password,
});
})
);
// Get password hint
router.get(
"/:namespace/_hint",
checkRestrictedUUID,
errors.catchAsync(async (req, res) => {
const { namespace } = req.params;
const device = await prisma.device.findUnique({
where: { uuid: namespace },
select: { passwordHint: true },
});
if (!device) {
return res.status(404).json({
statusCode: 404,
message: "设备不存在",
});
}
res.json({
passwordHint: device.passwordHint || null,
});
})
);
// Update password hint
router.put(
"/:namespace/_hint",
checkRestrictedUUID,
writeAuthMiddleware,
errors.catchAsync(async (req, res) => {
const { hint } = req.body;
const device = res.locals.device;
const updatedDevice = await prisma.device.update({
where: { uuid: device.uuid },
data: { passwordHint: hint },
select: { passwordHint: true },
});
res.json({
message: "密码提示已更新",
passwordHint: updatedDevice.passwordHint,
});
})
);
// Update device password
router.post(
"/:namespace/_password",
writeAuthMiddleware,
errors.catchAsync(async (req, res, next) => {
const { newPassword, oldPassword } = req.body;
const { password, oldPassword } = req.body;
const device = res.locals.device;
try {
if (device.password && oldPassword !== device.password) {
// 验证旧密码
if (
device.password &&
!(await verifyPassword(oldPassword, device.password))
) {
return next(errors.createError(500, "密码错误"));
}
// 对新密码进行哈希处理
const hashedPassword = await hashPassword(password);
if (!hashedPassword) {
return next(errors.createError(400, "新密码格式无效"));
}
await prisma.device.update({
where: { uuid: device.uuid },
data: { password: newPassword },
data: {
password: hashedPassword,
accessType: VALID_ACCESS_TYPES[1], // 设置密码时默认为受保护模式
},
});
res.json({ message: "密码已成功修改" });
@ -110,7 +208,9 @@ router.put(
// 验证 accessType
if (accessType && !VALID_ACCESS_TYPES.includes(accessType)) {
return res.status(400).json({
error: `Invalid access type. Must be one of: ${VALID_ACCESS_TYPES.join(", ")}`,
error: `Invalid access type. Must be one of: ${VALID_ACCESS_TYPES.join(
", "
)}`,
});
}

91
utils/crypto.js Normal file
View File

@ -0,0 +1,91 @@
import bcrypt from "bcrypt";
import { Base64 } from "js-base64";
const SALT_ROUNDS = 8;
/**
* base64 解码字符串
*/
export function decodeBase64(str) {
if (!str) return null;
try {
return Base64.decode(str);
} catch (error) {
return null;
}
}
/**
* 对字符串进行 UTF-8 编码处理
*/
function encodeUTF8(str) {
try {
return encodeURIComponent(str);
} catch (error) {
return null;
}
}
/**
* 验证密码是否匹配 base64 解码
*/
export async function DecodeAndVerifyPassword(plainPassword, hashedPassword) {
if (!plainPassword || !hashedPassword) return false;
const decodedPassword = decodeBase64(plainPassword);
console.debug(decodedPassword);
if (!decodedPassword) return false;
const encodedPassword = encodeUTF8(decodedPassword);
console.debug(encodedPassword);
if (!encodedPassword) return false;
return await bcrypt.compare(encodedPassword, hashedPassword);
}
/**
* 验证密码是否匹配不解码 base64但处理 UTF-8
*/
export async function verifyPassword(plainPassword, hashedPassword) {
if (!plainPassword || !hashedPassword) return false;
const encodedPassword = encodeUTF8(plainPassword);
console.debug(encodedPassword);
if (!encodedPassword) return false;
return await bcrypt.compare(encodedPassword, hashedPassword);
}
/**
* 对密码进行哈希处理 base64 解码
*/
export async function DecodeAndhashPassword(plainPassword) {
if (!plainPassword) return null;
const decodedPassword = decodeBase64(plainPassword);
console.debug(decodedPassword);
if (!decodedPassword) return null;
const encodedPassword = encodeUTF8(decodedPassword);
if (!encodedPassword) return null;
console.debug(encodedPassword);
return await bcrypt.hash(encodedPassword, SALT_ROUNDS);
}
/**
* 对密码进行哈希处理不解码 base64但处理 UTF-8
*/
export async function hashPassword(plainPassword) {
if (!plainPassword) return null;
const encodedPassword = encodeUTF8(plainPassword);
if (!encodedPassword) return null;
console.debug(encodedPassword);
return await bcrypt.hash(encodedPassword, SALT_ROUNDS);
}
/**
* 验证站点密钥
*/
export function verifySiteKey(providedKey, actualKey) {
if (!actualKey) return true; // 如果没有设置站点密钥,则总是通过
if (!providedKey) return false;
const decodedKey = decodeBase64(providedKey);
if (!decodedKey) return false;
const encodedKey = encodeUTF8(decodedKey);
if (!encodedKey) return false;
console.debug(encodedKey);
return encodedKey === actualKey;
}

2139
yarn.lock Normal file

File diff suppressed because it is too large Load Diff