Files
hzdyjs/docs/设计文档/database/oss/minio-security-solution.md
T
2025-08-02 21:06:47 +08:00

14 KiB
Raw Blame History

MinIO 安全访问控制解决方案

🎯 问题背景

当前 matchingUrl 方法只是简单地生成120秒的临时URL,缺乏更严格的权限控制,存在以下安全风险:

  1. 临时URL可能被泄露:120秒的临时URL可能被恶意用户获取并分享
  2. 缺乏用户身份验证:无法确保只有登录用户才能访问
  3. 无访问日志记录:无法追踪文件访问行为
  4. 权限控制不足:无法根据业务需求进行细粒度权限控制

🛡️ 安全解决方案

方案一:基于后端代理的强制权限验证(推荐)

核心特性

  • ✅ 强制登录验证:只有登录用户才能访问文件
  • ✅ 文件所有者权限:只有文件上传者或超级管理员可以访问
  • ✅ 访问日志记录:记录所有文件访问行为
  • ✅ 后端代理下载:通过后端代理下载,不直接暴露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;
}

📊 方案对比

特性 当前方案 方案一(推荐) 方案二 方案三
安全性 ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
实现复杂度 ⭐ ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
性能影响 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐
权限控制 ⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐
日志记录 ❌ ✅ ✅ ✅
业务扩展性 ⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐

🚀 推荐实施方案

阶段一:基础安全升级(立即实施)

  1. 实施方案一:基于后端代理的强制权限验证
  2. 更新现有接口:将 matchingUrl 方法标记为内部使用
  3. 添加安全接口:实现 getSecureFileUrl 和 secureDownload 方法

阶段二:高级安全特性(后续实施)

  1. 实施方案二:基于Token的临时访问控制
  2. 实施方案三:基于角色的细粒度权限控制
  3. 添加审计日志:完整的文件访问审计系统

阶段三:监控和优化(长期实施)

  1. 访问行为分析:基于访问日志进行安全分析
  2. 异常检测:检测异常的文件访问行为
  3. 性能优化:优化文件访问性能

📝 使用指南

前端调用示例

// 获取安全文件访问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文件访问的安全性:

  1. 强制用户身份验证:确保只有登录用户才能访问文件
  2. 细粒度权限控制:根据用户角色、部门等控制访问权限
  3. 完整的审计日志:记录所有文件访问行为
  4. 后端代理访问:避免直接暴露OSS URL
  5. 异常行为检测:及时发现和处理异常访问

推荐优先实施方案一,它提供了最佳的安全性和实现复杂度的平衡。