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:
parent
b312756d5f
commit
cf646d619f
@ -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({
|
||||
|
@ -5,6 +5,7 @@ const errorHandler = (err, req, res, next) => {
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
console.error(err);
|
||||
|
||||
try {
|
||||
if (isDevelopment) {
|
||||
|
@ -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
32
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
@ -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())
|
||||
|
112
routes/kv.js
112
routes/kv.js
@ -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
91
utils/crypto.js
Normal 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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user