当前 matchingUrl 方法只是简单地生成120秒的临时URL,缺乏更严格的权限控制,存在以下安全风险:
/**
* 🔐 安全文件访问URL生成(推荐使用)
* 通过后端代理进行权限验证,确保只有登录用户才能访问
*/
public String getSecureFileUrl(Long ossId) {
// 权限验证
if (!checkFileAccessPermission(ossId)) {
throw new ServiceException("无权限访问该文件");
}
// 记录访问日志
try {
Long userId = LoginHelper.getUserId();
String ipAddress = ServletUtils.getClientIP();
String userAgent = ServletUtils.getRequest().getHeader("User-Agent");
logFileAccess(ossId, userId, "SECURE_ACCESS", ipAddress, userAgent);
} catch (Exception e) {
// 日志记录失败不影响主流程
}
// 返回后端代理URL,而不是直接返回OSS URL
return "/system/oss/download/" + ossId;
}
/**
* 🔐 安全文件下载(通过后端代理)
* 强制权限验证,确保只有登录用户才能下载
*/
public void secureDownload(Long ossId, HttpServletResponse response) throws IOException {
// 强制权限验证
if (!checkFileAccessPermission(ossId)) {
throw new ServiceException("无权限下载该文件");
}
SysOssVo sysOss = getById(ossId);
if (ObjectUtil.isNull(sysOss)) {
throw new ServiceException("文件不存在");
}
// 记录下载日志
try {
Long userId = LoginHelper.getUserId();
String ipAddress = ServletUtils.getClientIP();
String userAgent = ServletUtils.getRequest().getHeader("User-Agent");
logFileAccess(ossId, userId, "DOWNLOAD", ipAddress, userAgent);
} catch (Exception e) {
// 日志记录失败不影响主流程
}
// 设置响应头
FileUtils.setAttachmentResponseHeader(response, sysOss.getOriginalName());
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE + "; charset=UTF-8");
// 通过OSS客户端下载并直接输出到响应流
OssClient storage = OssFactory.instance(sysOss.getService());
storage.download(sysOss.getFileName(), response.getOutputStream(), response::setContentLengthLong);
}
/**
* 🔐 增强版权限检查
* 包含用户身份验证、文件访问权限、业务权限等多重验证
*/
@Override
public boolean checkFileAccessPermission(Long ossId) {
// 1. 基础检查:文件必须存在
SysOssVo oss = getById(ossId);
if (ObjectUtil.isNull(oss)) {
return false;
}
// 2. 用户身份验证:必须已登录
try {
Long userId = LoginHelper.getUserId();
if (ObjectUtil.isNull(userId)) {
return false;
}
} catch (Exception e) {
return false;
}
// 3. 文件所有者检查:只有文件上传者可以访问
if (ObjectUtil.isNotNull(oss.getCreateBy())) {
Long currentUserId = LoginHelper.getUserId();
if (!oss.getCreateBy().equals(currentUserId)) {
// 如果不是文件所有者,检查是否有超级管理员权限
if (!LoginHelper.isSuperAdmin()) {
return false;
}
}
}
// 4. 业务权限检查(可根据具体业务需求扩展)
// 例如:检查用户是否有访问该文件所属模块的权限
// 这里可以添加更复杂的业务逻辑
return true;
}
/**
* 🔐 安全文件下载接口
* 强制权限验证,确保只有登录用户才能下载文件
*/
@SaCheckPermission("system:oss:download")
@GetMapping("/download/{ossId}")
public void secureDownload(@PathVariable("ossId") Long ossId, HttpServletResponse response) {
try {
ossService.secureDownload(ossId, response);
} catch (Exception e) {
log.error("安全文件下载失败: {}", e.getMessage(), e);
try {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("无权限下载该文件");
} catch (IOException ex) {
log.error("写入错误响应失败", ex);
}
}
}
/**
* 🔐 获取安全文件访问URL
* 返回通过后端代理的安全访问URL,而不是直接返回OSS URL
*/
@SaCheckPermission("system:oss:access")
@GetMapping("/secure-url/{ossId}")
public R<String> getSecureFileUrl(@PathVariable("ossId") Long ossId) {
try {
String secureUrl = ossService.getSecureFileUrl(ossId);
return R.ok(secureUrl);
} catch (Exception e) {
log.error("获取安全文件URL失败: {}", e.getMessage(), e);
return R.fail("无权限访问该文件");
}
}
/**
* 生成一次性临时访问Token
*/
public String generateTemporaryToken(Long ossId, String ipAddress) {
// 生成唯一Token
String token = UUID.randomUUID().toString();
// 设置Token信息(存储到Redis,5秒过期)
String tokenKey = "file_access_token:" + token;
Map<String, Object> tokenInfo = new HashMap<>();
tokenInfo.put("ossId", ossId);
tokenInfo.put("ipAddress", ipAddress);
tokenInfo.put("createTime", System.currentTimeMillis());
tokenInfo.put("accessCount", 0);
redisTemplate.opsForValue().set(tokenKey, tokenInfo, Duration.ofSeconds(5));
return token;
}
/**
* 验证临时访问Token
*/
public boolean validateTemporaryToken(String token, String ipAddress) {
String tokenKey = "file_access_token:" + token;
Map<String, Object> tokenInfo = (Map<String, Object>) redisTemplate.opsForValue().get(tokenKey);
if (tokenInfo == null) {
return false; // Token不存在或已过期
}
// 检查IP地址
if (!ipAddress.equals(tokenInfo.get("ipAddress"))) {
return false;
}
// 检查访问次数(限制为1次)
Integer accessCount = (Integer) tokenInfo.get("accessCount");
if (accessCount >= 1) {
redisTemplate.delete(tokenKey);
return false;
}
// 更新访问次数
tokenInfo.put("accessCount", accessCount + 1);
redisTemplate.opsForValue().set(tokenKey, tokenInfo, Duration.ofSeconds(5));
return true;
}
/**
* 基于角色的文件访问权限检查
*/
public boolean checkRoleBasedPermission(Long ossId, Long userId) {
SysOssVo oss = getById(ossId);
if (ObjectUtil.isNull(oss)) {
return false;
}
// 获取用户角色
List<String> userRoles = LoginHelper.getLoginUser().getRoles();
// 超级管理员可以访问所有文件
if (userRoles.contains("superadmin")) {
return true;
}
// 根据文件类型检查权限
String fileType = getFileType(oss.getFileName());
switch (fileType) {
case "confidential":
return userRoles.contains("admin") || userRoles.contains("manager");
case "internal":
return userRoles.contains("admin") || userRoles.contains("manager") || userRoles.contains("staff");
case "public":
return true;
default:
return false;
}
}
/**
* 基于部门的文件访问权限检查
*/
public boolean checkDepartmentPermission(Long ossId, Long userId) {
SysOssVo oss = getById(ossId);
if (ObjectUtil.isNull(oss)) {
return false;
}
// 获取文件所属部门
Long fileDeptId = oss.getDeptId(); // 假设文件有部门ID字段
// 获取用户所属部门
Long userDeptId = LoginHelper.getLoginUser().getDeptId();
// 同部门用户可以访问
if (fileDeptId.equals(userDeptId)) {
return true;
}
// 上级部门可以访问下级部门的文件
if (isParentDepartment(userDeptId, fileDeptId)) {
return true;
}
return false;
}
| 特性 | 当前方案 | 方案一(推荐) | 方案二 | 方案三 |
|---|---|---|---|---|
| 安全性 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 实现复杂度 | ⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 性能影响 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 权限控制 | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 日志记录 | ❌ | ✅ | ✅ | ✅ |
| 业务扩展性 | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
matchingUrl 方法标记为内部使用getSecureFileUrl 和 secureDownload 方法// 获取安全文件访问URL
const getSecureFileUrl = async (ossId) => {
try {
const response = await request.get(`/system/oss/secure-url/${ossId}`);
if (response.code === 200) {
return response.data; // 返回后端代理URL
}
} catch (error) {
console.error('获取安全文件URL失败:', error);
}
};
// 下载文件(通过后端代理)
const downloadFile = async (ossId) => {
try {
const response = await request.get(`/system/oss/download/${ossId}`, {
responseType: 'blob'
});
// 创建下载链接
const blob = new Blob([response]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'filename.ext';
link.click();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('文件下载失败:', error);
}
};
// 移动端安全文件下载
const downloadSecureFile = async (ossId) => {
try {
const response = await uni.request({
url: `/system/oss/download/${ossId}`,
method: 'GET',
header: {
'Authorization': `Bearer ${token}`
}
});
// 保存文件到本地
const filePath = `${wx.env.USER_DATA_PATH}/download_${ossId}.file`;
await uni.saveFile({
tempFilePath: response.data,
filePath: filePath
});
uni.showToast({
title: '文件下载成功',
icon: 'success'
});
} catch (error) {
uni.showToast({
title: '文件下载失败',
icon: 'none'
});
}
};
-- 添加文件访问权限
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES ('文件安全访问', 3, 6, 'oss', 'system/oss/index', 1, 0, 'C', '0', '0', 'system:oss:access', 'download', 'admin', sysdate(), '', null, '文件安全访问菜单');
-- 添加下载权限
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES ('文件下载', 3, 7, 'oss', 'system/oss/index', 1, 0, 'C', '0', '0', 'system:oss:download', 'download', 'admin', sysdate(), '', null, '文件下载菜单');
# application.yml
spring:
redis:
host: localhost
port: 6379
database: 0
timeout: 10s
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0
通过实施这些安全方案,可以显著提升MinIO文件访问的安全性:
推荐优先实施方案一,它提供了最佳的安全性和实现复杂度的平衡。