这是手写对象存储专栏的第8章,我们将从更新用户模块开始,确保其与最新的 BaseModel 兼容。然后,我们将设计和实现文件访问权限模型,定义文件的访问规则,并创建相关的 API 和中间件来处理权限控制。最后,我们将实现临时访问链接的生成和校验功能,使得用户可以在限定时间内访问文件。
通过本章的学习,您将掌握如何在 Express 应用中实现复杂的文件访问权限管理,并提供安全、灵活的文件访问方式。
更新用户模块
在权限控制与安全机制正文开始之前,我们需要更新用户模块以适应第五章对 BaseModel 的更改。以下是如何在用户模块中实现这些更新的具体步骤。
1. 更新 BaseModel
确保 BaseModel 已经按照第五章的要求进行了更新。最新可以看回看一下第五章
2. 创建用户模型
基于更新后的 BaseModel 创建用户模型,并添加用户特有的字段和方法。
在 models 目录下创建 UserModel.ts 文件:
import mongoose from "mongoose";
import bcrypt from "bcryptjs";
import BaseModel, { IBaseDocument } from "./BaseModel"; // 引入更新后的 BaseModel
// 用户接口,包含字段和方法
export interface IUser extends IBaseDocument {
username: string;
email: string;
password: string;
role: "user" | "admin" | "super";
comparePassword(candidatePassword: string): Promise<boolean>;
}
// 用户 Schema 定义
const userSchemaDefinition: mongoose.SchemaDefinition = {
username: { type: String, required: true, unique: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
role: { type: String, enum: ["user", "admin", "super"], default: "user" },
};
class UserModel extends BaseModel<IUser> {
constructor() {
super("User", userSchemaDefinition);
// 保存用户前加密密码
this.getModel().schema.pre<IUser>("save", async function (next) {
if (!this.isModified("password")) return next();
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash(this.password, salt);
this.password = hash;
next();
});
// 比较密码方法
this.getModel().schema.methods.comparePassword = function (
candidatePassword: string
): Promise<boolean> {
return bcrypt.compare(candidatePassword, this.password);
};
}
// 单独的比较密码方法
comparePassword(candidatePassword: string, hashPass: string) {
return bcrypt.compare(candidatePassword, hashPass);
}
}
export default new UserModel();
3. 更新控制器
authController.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Request, Response } from "express";
import UserModel from "../models/UserModel";
import jwt from "jsonwebtoken";
// 注册用户
export const register = async (req: Request, res: Response) => {
const { username, email, password } = req.body;
try {
await UserModel.create({ username, email, password });
res.status(201).json({ message: "User registered successfully" });
} catch (error:any) {
if (error.code === 11000) { // 处理唯一键冲突错误
res.status(400).json({ message: "User already exists" });
} else {
console.error('Error registering user:', error);
res.status(500).json({ message: "Internal server error" });
}
}
};
// 用户登录
export const login = async (req: Request, res: Response) => {
const { email, password } = req.body;
try {
const user = await UserModel.findOne({ email });
if (!user) {
return res.status(400).json({ error: "Invalid credentials" });
}
const isMatch = await UserModel.comparePassword(password, user.password);
if (!isMatch) {
return res.status(400).json({ error: "Invalid credentials" });
}
const token = jwt.sign(
{ userId: user._id, role: user.role },
"your_jwt_secret",
{ expiresIn: "1h" }
);
res.setHeader("token", `${token}`);
res.status(200).json({ message: "Login successful", token });
} catch (error) {
console.error('Error during login:', error);
res.status(500).json({ error: "Internal server error" });
}
};
userController.ts
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Request, Response } from "express";
import UserModel from "../models/UserModel";
import { extractKeysFromObject } from "../utils/dict";
import mongoose from "mongoose";
const userFields = ["username", "email", "role"];
// 获取用户个人资料
export const getUserProfile = async (req: Request, res: Response) => {
try {
const user = await UserModel.findById(
new mongoose.Types.ObjectId(req.user!.userId)
);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const filteredUser = extractKeysFromObject(user.toObject(), userFields);
res.status(200).json(filteredUser);
} catch (error) {
console.error("Error fetching user profile:", error);
res.status(500).json({ error: "Server error" });
}
};
// 更新用户个人资料
export const updateUserProfile = async (req: Request, res: Response) => {
const { username, email } = req.body;
try {
const user = await UserModel.findById(
new mongoose.Types.ObjectId(req.user!.userId)
);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
user.username = username || user.username;
user.email = email || user.email;
await user.save();
const filteredUser = extractKeysFromObject(user.toObject(), userFields);
res
.status(200)
.json({ message: "User updated successfully", user: filteredUser });
} catch (error) {
console.error("Error updating user profile:", error);
res.status(500).json({ error: "Server error" });
}
};
// 更改密码
export const changePassword = async (req: Request, res: Response) => {
const { currentPassword, newPassword } = req.body;
try {
const user = await UserModel.findById(
new mongoose.Types.ObjectId(req.user!.userId)
);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const isMatch = await user.comparePassword(currentPassword);
if (!isMatch) {
return res.status(400).json({ error: "Current password is incorrect" });
}
user.password = newPassword;
await user.save();
res.status(200).json({ message: "Password changed successfully" });
} catch (error) {
console.error("Error changing password:", error);
res.status(500).json({ error: "Server error" });
}
};
// 用户登出
export const logout = (req: Request, res: Response) => {
// 清除客户端保存的JWT Token
res.status(200).json({ message: "User logged out successfully" });
};
// 升级为管理员
export const upgradeToAdmin = async (req: Request, res: Response) => {
const { userId } = req.body;
try {
const user = await UserModel.findById(userId);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
user.role = "admin";
await user.save();
const filteredUser = extractKeysFromObject(user.toObject(), userFields);
res.status(200).json({
message: "User upgraded to admin successfully",
user: filteredUser,
});
} catch (error) {
console.error("Error upgrading user to admin:", error);
res.status(500).json({ error: "Server error" });
}
};
以上步骤完成后,用户模块将更新以适应第五章对 BaseModel 的更改,并在第八章的权限控制中正常工作。
文件访问权限管理
设计权限模型
我们将设计一个简单的权限模型来管理文件的访问权限。文件可以有以下几种访问权限模式:
- 公开访问(Public Access):任何用户都可以访问文件。
- 私有访问(Private Access):只有文件所有者可以访问文件。
- 授权访问(Authorized Access):只有特定用户或角色可以访问文件。
定义权限模型
在文件模型中添加权限相关字段。假设我们有一个 ObjectMetadata 模型来管理文件的元数据,我们将在其中添加权限字段。
首先,更新 ObjectMetadata 模型以包含权限字段:
import mongoose from "mongoose";
import BaseModel, { IBaseDocument } from "./BaseModel";
// 定义文件权限类型
export type FileAccess = "public" | "private" | "authorized";
// 定义对象元数据接口
export interface IObjectMetadata extends IBaseDocument {
bucketId: mongoose.Types.ObjectId; // 新增字段
originalFileName: string;
storageFileName: string;
filePath: string;
fileSize: number;
fileType: string;
fileMd5: string;
access: FileAccess; // 新增字段
authorizedUsers?: mongoose.Types.ObjectId[]; // 授权用户列表
}
// 定义对象元数据Schema
const objectMetadataSchemaDefinition: mongoose.SchemaDefinition = {
bucketId: { type: mongoose.Types.ObjectId, ref: "Bucket", required: true }, // 新增字段
originalFileName: { type: String, required: true },
storageFileName: { type: String, required: true },
filePath: { type: String, required: true },
fileSize: { type: Number, required: true },
fileType: { type: String, required: true },
fileMd5: { type: String, required: true },
access: {
type: String,
enum: ["public", "private", "authorized"],
default: "private",
},
authorizedUsers: [{ type: mongoose.Types.ObjectId, ref: "User" }],
};
// 创建对象元数据模型类
class ObjectMetadataModel extends BaseModel<IObjectMetadata> {
constructor() {
super("ObjectMetadata", objectMetadataSchemaDefinition, {
timestamps: true,
});
}
}
export default new ObjectMetadataModel();
实现权限控制API
我们将创建中间件来检查用户权限,并在路由中使用该中间件。
首先,创建一个权限检查中间件 permissionMiddleware.ts:
import { Request, Response, NextFunction } from "express";
import ObjectMetadataModel from "../models/FileModel";
import mongoose from "mongoose";
export const checkFileAccess = async (
req: Request,
res: Response,
next: NextFunction
) => {
const fileId = req.params.fileId;
const userId = req.user?.userId;
try {
const file = await ObjectMetadataModel.findById(
new mongoose.Types.ObjectId(fileId)
);
if (!file) {
return res.status(404).json({ error: "File not found" });
}
if (file.access === "public") {
return next(); // 公开访问,任何人都可以访问
}
if (file.access === "private" && file.createdBy?.toString() === userId) {
return next(); // 私有访问,只有文件所有者可以访问
}
if (
userId !== undefined &&
file.access === "authorized" &&
file.authorizedUsers?.includes(new mongoose.Types.ObjectId(userId))
) {
return next(); // 授权访问,只有特定用户可以访问
}
return res.status(403).json({ error: "Access denied" });
} catch (error) {
console.error("Error checking file access:", error);
res.status(500).json({ error: "Server error" });
}
};
然后,在路由fileRoutes.ts中使用该中间件:
// src/routes/fileRoutes.ts
import express from "express";
import upload from "../config/multerConfig";
import { authMiddleware } from "../middlewares/authMiddleware";
import {
initiateUpload,
uploadChunk,
uploadMultipleFiles,
uploadSingleFile,
} from "../controllers/files/uploadController";
import {
downloadFile,
getFileList,
previewFile,
} from "../controllers/files/listController";
import { checkFileAccess } from "../middlewares/permissionMiddleware";
import { verifyTemporaryLink } from "../middlewares/tempLinkMiddleware";
import { roleMiddleware } from "../middlewares/roleMiddleware";
import { generateTemporaryLink, getFileByTemporaryLink } from "../controllers/linkController";
const router = express.Router();
// 单文件上传
router.post("/upload/single", authMiddleware, uploadSingleFile);
// 多文件上传
router.post("/upload/multiple", authMiddleware, uploadMultipleFiles);
// 分片上传
// 初始化任务
router.post("/upload/initiate", authMiddleware, initiateUpload);
// 上传分片
router.post(
"/upload/chunk",
authMiddleware,
upload.single("chunk"),
uploadChunk
);
router.get("/list", authMiddleware, getFileList);
router.get("/download/:fileId", authMiddleware, checkFileAccess, downloadFile);
router.get("/preview/:fileId", authMiddleware, checkFileAccess, previewFile);
// 生成临时访问链接--controllers定义在下面
router.post(
"/generate-link/:fileId",
authMiddleware,
roleMiddleware(["admin", "super"]),
generateTemporaryLink
);
// 使用临时访问链接获取文件--controllers定义在下面
router.get("/temp-file/:token", verifyTemporaryLink, getFileByTemporaryLink);
export default router;
通过以上步骤,我们实现了文件访问权限管理。我们定义了权限模型,在文件模型中添加了权限相关字段,并创建了权限控制中间件和相关的 API。这样一来,可以确保只有符合条件的用户才能访问、更新或删除文件。
临时访问链接的生成与校验
生成临时访问链接
我们将创建一个 API 来生成临时访问链接。临时访问链接将包含一个签名和一个过期时间,以确保其安全性和时效性。
首先,创建生成临时访问链接的控制器 linkController.ts:
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Request, Response } from "express";
import jwt from "jsonwebtoken";
import ObjectMetadataModel from "../models/FileModel";
import mongoose from "mongoose";
import fs from "fs";
const secret = "your_jwt_secret"; // 应该存储在环境变量或者专门的config模块中,这个下一章会专门处理
// 生成临时访问链接
export const generateTemporaryLink = async (req: Request, res: Response) => {
const { fileId } = req.params;
const { expiresIn } = req.body; // 过期时间,单位秒
try {
const file = await ObjectMetadataModel.findById(new mongoose.Types.ObjectId(fileId));
if (!file) {
return res.status(404).json({ error: "File not found" });
}
// 创建 JWT token
const token = jwt.sign(
{
fileId: file._id,
},
secret,
{ expiresIn }
);
// 生成临时链接
const temporaryLink = `${req.protocol}://${req.get("host")}/api/files/temp-file/${token}`;
res.status(200).json({ temporaryLink });
} catch (error) {
console.error("Error generating temporary link:", error);
res.status(500).json({ error: "Server error" });
}
};
然后,在路由中添加生成临时访问链接的路由 fileRoutes.ts(上一小节中添加过了)
校验临时访问链接
创建一个中间件来校验临时访问链接,并在路由中使用该中间件。
首先,创建校验临时访问链接的中间件 tempLinkMiddleware.ts:
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
declare global {
namespace Express {
interface Request {
decodedToken?: {
fileId: string;
};
}
}
}
const secret = process.env.JWT_SECRET || "your_jwt_secret"; // 应该存储在环境变量中
export const verifyTemporaryLink = (
req: Request,
res: Response,
next: NextFunction
) => {
const token = req.params.token;
try {
const decodedToken: any = jwt.verify(token, secret);
req.decodedToken = decodedToken; // 将解码后的 token 信息存储在请求对象中,以便后续控制器使用
next();
} catch (error) {
console.error("Error verifying temporary link:", error);
res.status(400).json({ error: "Invalid or expired link" });
}
};
然后,在路由中使用该中间件来处理临时访问链接 fileRoutes.ts(上一小节中添加过了)
最后,更新 linkController.ts 中的 getFile 方法,以便通过临时链接访问文件:
import { Request, Response } from "express";
import ObjectMetadataModel from "../models/ObjectMetadata";
import mongoose from "mongoose";
// 获取文件通过临时链接
export const getFileByTemporaryLink = async (req: Request, res: Response) => {
const { fileId } = req.decodedToken || {};
try {
const fileMetadata = await ObjectMetadataModel.findOne({
_id: new mongoose.Types.ObjectId(fileId),
});
if (!fileMetadata) {
res.status(404).json({ message: "File not found" });
return;
}
const filePath = fileMetadata.filePath;
if (fs.existsSync(filePath)) {
const fileStream = fs.createReadStream(filePath);
fileStream.pipe(res);
fileStream.on("error", (err) => {
res
.status(500)
.json({ message: "File preview failed", error: err.message });
});
} else {
res.status(404).json({ message: "File not found" });
}
} catch (error: any) {
res
.status(500)
.json({ message: "File preview failed", error: error.message });
}
};
通过以上步骤,我们实现了生成和校验临时访问链接的功能。用户可以生成一个包含签名和过期时间的临时访问链接,并使用该链接访问文件。访问时,服务器会验证链接的有效性和时效性,以确保文件访问的安全性。
总结
在本章中,我们深入探讨了如何在 Express 应用中实现文件访问权限管理和临时访问链接。首先,我们更新了用户模块,以确保其与最新的 BaseModel 兼容。接着,我们设计并实现了文件访问权限模型,定义了文件的访问规则,并创建了相关的 API 和中间件来处理权限控制。我们通过详细的代码示例展示了如何保护文件访问,并确保只有授权用户才能执行特定操作。
最后,我们介绍了生成和校验临时访问链接的功能,使得用户可以在有限的时间内安全地访问文件。这一功能大大增强了应用的灵活性和安全性。
发文之前我在检查代码中发现少了关于桶权限对于文件权限影响的讨论,这个在后期迭代中我们再仔细讨论
附件——接口文档
更新用户模块
用户注册
- URL:
/api/auth/register - 方法:
POST - 描述: 注册新用户
- 请求体:
{ "username": "string", "email": "string", "password": "string" } - 响应:
- 成功:
- 状态码:
201 Created - 响应体:
{ "message": "User registered successfully" }
- 状态码:
- 失败:
- 状态码:
400 Bad Request或500 Internal Server Error - 响应体:
{ "message": "User already exists" // 或 "Internal server error" }
- 状态码:
- 成功:
用户登录
- URL:
/api/auth/login - 方法:
POST - 描述: 用户登录
- 请求体:
{ "email": "string", "password": "string" } - 响应:
- 成功:
- 状态码:
200 OK - 响应体:
{ "message": "Login successful", "token": "jwt_token" }
- 状态码:
- 失败:
- 状态码:
400 Bad Request或500 Internal Server Error - 响应体:
{ "error": "Invalid credentials" // 或 "Internal server error" }
- 状态码:
- 成功:
获取用户个人资料
- URL:
/api/user/profile - 方法:
GET - 描述: 获取当前登录用户的个人资料
- 请求头:
Authorization:Bearer jwt_token
- 响应:
- 成功:
- 状态码:
200 OK - 响应体:
{ "username": "string", "email": "string", "role": "string" }
- 状态码:
- 失败:
- 状态码:
404 Not Found或500 Internal Server Error - 响应体:
{ "error": "User not found" // 或 "Server error" }
- 状态码:
- 成功:
更新用户个人资料
- URL:
/api/user/profile - 方法:
PUT - 描述: 更新当前登录用户的个人资料
- 请求头:
Authorization:Bearer jwt_token
- 请求体:
{ "username": "string", "email": "string" } - 响应:
- 成功:
- 状态码:
200 OK - 响应体:
{ "message": "User updated successfully", "user": { "username": "string", "email": "string", "role": "string" } }
- 状态码:
- 失败:
- 状态码:
404 Not Found或500 Internal Server Error - 响应体:
{ "error": "User not found" // 或 "Server error" }
- 状态码:
- 成功:
更改密码
- URL:
/api/user/change-password - 方法:
PUT - 描述: 更改当前登录用户的密码
- 请求头:
Authorization:Bearer jwt_token
- 请求体:
{ "currentPassword": "string", "newPassword": "string" } - 响应:
- 成功:
- 状态码:
200 OK - 响应体:
{ "message": "Password changed successfully" }
- 状态码:
- 失败:
- 状态码:
400 Bad Request或404 Not Found或500 Internal Server Error - 响应体:
{ "error": "Current password is incorrect" // 或 "User not found" // 或 "Server error" }
- 状态码:
- 成功:
用户登出
- URL:
/api/auth/logout - 方法:
POST - 描述: 用户登出
- 响应:
- 成功:
- 状态码:
200 OK - 响应体:
{ "message": "User logged out successfully" }
- 状态码:
- 成功:
升级为管理员
- URL:
/api/user/upgrade-to-admin - 方法:
POST - 描述: 将用户角色升级为管理员
- 请求头:
Authorization:Bearer jwt_token
- 请求体:
{ "userId": "string" } - 响应:
- 成功:
- 状态码:
200 OK - 响应体:
{ "message": "User upgraded to admin successfully", "user": { "username": "string", "email": "string", "role": "string" } }
- 状态码:
- 失败:
- 状态码:
404 Not Found或500 Internal Server Error - 响应体:
{ "error": "User not found" // 或 "Server error" }
- 状态码:
- 成功:
文件访问权限管理
获取文件列表
- URL:
/api/files/list - 方法:
GET - 描述: 获取当前用户的文件列表
- 请求头:
Authorization:Bearer jwt_token
- 响应:
- 成功:
- 状态码:
200 OK - 响应体:
[ { "fileId": "string", "originalFileName": "string", "filePath": "string", "fileSize": "number", "fileType": "string", "fileMd5": "string", "access": "public/private/authorized", "authorizedUsers": ["string"] } ]
- 状态码:
- 成功:
下载文件
- URL:
/api/files/download/:fileId - 方法:
GET - 描述: 下载指定文件
- 请求头:
Authorization:Bearer jwt_token
- 响应:
- 成功:
- 状态码:
200 OK - 响应体: 文件流
- 状态码:
- 失败:
- 状态码:
403 Forbidden或404 Not Found或500 Internal Server Error - 响应体:
{ "error": "Access denied" // 或 "File not found" // 或 "Server error" }
- 状态码:
- 成功:
预览文件
- URL:
/api/files/preview/:fileId - 方法:
GET - 描述: 预览指定文件
- 请求头:
Authorization:Bearer jwt_token
- 响应:
- 成功:
- 状态码:
200 OK - 响应体: 文件流
- 状态码:
- 失败:
- 状态码:
403 Forbidden或404 Not Found或500 Internal Server Error - 响应体:
{ "error": "Access denied" // 或 "File not found" // 或 "Server error" }
- 状态码:
- 成功:
临时访问链接的生成与校验
生成临时访问链接
- URL:
/api/files/generate-link/:fileId - 方法:
POST - 描述: 生成用于临时访问指定文件的链接
- 请求头:
Authorization:Bearer jwt_token
- 请求体:
{ "expiresIn": "number" // 过期时间,单位秒 } - 响应:
- 成功:
- 状态码:
200 OK - 响应体:
{ "temporaryLink": "string" }
- 状态码:
- 失败:
- 状态码:
404 Not Found或500 Internal Server Error - 响应体:
{ "error": "File not found" // 或 "Server error" }
- 状态码:
- 成功:
使用临时访问链接获取文件
-
URL:
/api/files/temp-file/:token -
方法:
GET -
描述: 使用临时访问链接访问文件
-
响应:
- 成功:
- 状态码:
200 OK - 响应体: 文件流
- 状态码:
- 失败:
- 状态码:
400 Bad Request或404 Not Found或500 Internal Server Error - 响应体:
{ "error": "Invalid or expired link" // 或 "File not found" // 或 "File preview failed" }
- 状态码:
- 成功:
以上就是本章中涉及的所有 API 接口的详细文档。