14 KiB
14 KiB
MinIO 安全访问控制解决方案
🎯 问题背景
当前 matchingUrl 方法只是简单地生成120秒的临时URL,缺乏更严格的权限控制,存在以下安全风险:
- 临时URL可能被泄露:120秒的临时URL可能被恶意用户获取并分享
- 缺乏用户身份验证:无法确保只有登录用户才能访问
- 无访问日志记录:无法追踪文件访问行为
- 权限控制不足:无法根据业务需求进行细粒度权限控制
🛡️ 安全解决方案
方案一:基于后端代理的强制权限验证(推荐)
核心特性
- ✅ 强制登录验证:只有登录用户才能访问文件
- ✅ 文件所有者权限:只有文件上传者或超级管理员可以访问
- ✅ 访问日志记录:记录所有文件访问行为
- ✅ 后端代理下载:通过后端代理下载,不直接暴露OSS 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;
}
Controller接口
/**
* 🔐 安全文件下载接口
* 强制权限验证,确保只有登录用户才能下载文件
*/
@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的临时访问控制
核心特性
- ✅ 一次性Token:每次访问生成唯一的临时Token
- ✅ 短时间有效:Token有效期通常为5-30秒
- ✅ 访问计数限制:限制Token的使用次数
- ✅ IP绑定:Token与请求IP绑定
实现思路
/**
* 生成一次性临时访问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方法
阶段二:高级安全特性(后续实施)
- 实施方案二:基于Token的临时访问控制
- 实施方案三:基于角色的细粒度权限控制
- 添加审计日志:完整的文件访问审计系统
阶段三:监控和优化(长期实施)
- 访问行为分析:基于访问日志进行安全分析
- 异常检测:检测异常的文件访问行为
- 性能优化:优化文件访问性能
📝 使用指南
前端调用示例
// 获取安全文件访问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, '文件下载菜单');
Redis配置(方案二)
# 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
📈 监控指标
关键指标
- 文件访问成功率:安全访问接口的成功率
- 权限拒绝率:因权限不足被拒绝的访问次数
- 异常访问检测:检测到的异常访问行为
- 下载性能:文件下载的平均响应时间
告警规则
- 权限拒绝率 > 10%:可能存在权限配置问题
- 异常访问 > 5次/分钟:可能存在恶意访问
- 下载失败率 > 5%:可能存在系统问题
🎯 总结
通过实施这些安全方案,可以显著提升MinIO文件访问的安全性:
- 强制用户身份验证:确保只有登录用户才能访问文件
- 细粒度权限控制:根据用户角色、部门等控制访问权限
- 完整的审计日志:记录所有文件访问行为
- 后端代理访问:避免直接暴露OSS URL
- 异常行为检测:及时发现和处理异常访问
推荐优先实施方案一,它提供了最佳的安全性和实现复杂度的平衡。