You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

邮件中文档下载URL的身份验证方案咨询

问题

我们的应用里,用户创建预约时可以上传/关联文档到预约单,预约创建完成后,超级用户(Agent)会收到邮件,里面有个表格列了所有附件的下载URL。
现在要求这些下载URL只能让已登录的授权用户访问,但后端API平时都是用Bearer Token做身份验证的,邮件里的普通URL没法带Token。

之前试过在URL里加固定密钥,服务器验证密钥是否正确,但这有安全问题——只要未授权用户拿到邮件或URL,就能直接下载文档。求靠谱的解决办法。

之前的实现代码:

[HttpGet, Route("download-doc/{key}/{appointmentID}/{documentID}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[AllowAnonymous]
public async Task<IActionResult> DownloadOpenDocument(string key, string appointmentID, string documentID)
{
    try
    {
        var docKey = _configuration.GetValue<string>("Entities:DMSOpenKey");
        if (string.IsNullOrWhiteSpace(key) || key != docKey)
            throw new UnauthorizedAccessException("You don't have required permissions for this access.");
        var (documentBytes, contentType) = await _apptDocumentService.DownloadDocument(appointmentID, documentID);
        return File(documentBytes, contentType);
    }
    catch (ArgumentException ex)
    {
        return HandleUserException(ex);
    }
    catch (Exception ex)
    {
        return HandleOtherException(ex);
    }
}
解决思路

1. 带签名和过期时间的一次性URL

这是最常用的安全方案,核心是给每个下载URL绑定唯一身份、过期时间并签名:

  • 生成URL时,把用户ID、预约ID、文档ID、过期时间戳用HMAC算法签名,将签名和参数一起拼在URL中
  • 服务器接收到请求时:
    • 先检查URL是否过期
    • 验证签名合法性(防止参数被篡改)
    • 校验当前登录用户ID是否和URL绑定的用户ID一致,且该用户确实有权限访问目标文档

2. 跳转登录页后校验权限

把邮件里的URL改成一个中间跳转页:

  • 用户点击URL后,先跳转到应用登录页(未登录状态下)
  • 登录成功后,后端直接校验该用户是否有权限访问对应文档,有权限则返回文件流,无权限返回403

3. Cookie验证(适合Web端场景)

如果应用是前后端同构或支持Cookie认证,直接将下载接口改为Cookie验证:

  • 用户点击URL时,浏览器自动携带登录状态Cookie(只要用户曾在该设备登录且Cookie未过期)
  • 后端校验Cookie有效性后,再检查用户权限
优化后的代码示例(方案1)
// 生成签名下载URL的工具方法
private string GenerateSignedDownloadUrl(string userId, string appointmentId, string documentId)
{
    var secretKey = _configuration.GetValue<string>("SigningSecret");
    var expirationMinutes = _configuration.GetValue<int>("DownloadUrlExpirationMinutes");
    var expirationTimestamp = DateTimeOffset.UtcNow.AddMinutes(expirationMinutes).ToUnixTimeSeconds();

    // 整理待签名参数,按key排序保证签名一致性
    var parameters = new Dictionary<string, string>
    {
        {"userId", userId},
        {"appointmentId", appointmentId},
        {"documentId", documentId},
        {"exp", expirationTimestamp.ToString()}
    };

    // 生成URL安全的HMAC签名
    var paramString = string.Join("&", parameters.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}={kv.Value}"));
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey));
    var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(paramString));
    var signature = Convert.ToBase64String(hashBytes)
        .Replace('+', '-')
        .Replace('/', '_')
        .TrimEnd('=');

    // 拼接最终下载URL
    var baseUrl = $"{Request.Scheme}://{Request.Host}/api/download-doc";
    var queryString = $"{paramString}&sig={signature}";
    return $"{baseUrl}?{queryString}";
}

// 下载接口实现,要求用户必须登录
[HttpGet, Route("download-doc")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[Authorize]
public async Task<IActionResult> DownloadSignedDocument(
    [FromQuery] string userId, 
    [FromQuery] string appointmentId, 
    [FromQuery] string documentId, 
    [FromQuery] long exp, 
    [FromQuery] string sig)
{
    try
    {
        // 1. 检查链接是否过期
        if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > exp)
            return Forbid("下载链接已过期");

        // 2. 验证签名有效性
        var secretKey = _configuration.GetValue<string>("SigningSecret");
        var parameters = new Dictionary<string, string>
        {
            {"userId", userId},
            {"appointmentId", appointmentId},
            {"documentId", documentId},
            {"exp", exp.ToString()}
        };
        var paramString = string.Join("&", parameters.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}={kv.Value}"));
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey));
        var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(paramString));
        var expectedSignature = Convert.ToBase64String(hashBytes)
            .Replace('+', '-')
            .Replace('/', '_')
            .TrimEnd('=');
        
        if (sig != expectedSignature)
            return Forbid("无效的下载链接");

        // 3. 校验当前用户是否为链接绑定用户
        var currentUserId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (currentUserId != userId)
            return Forbid("无权限访问该文档");

        // 4. 业务层权限校验(根据实际逻辑调整)
        var hasPermission = await _apptDocumentService.HasAccessToDocument(currentUserId, appointmentId, documentId);
        if (!hasPermission)
            return Forbid("无权限访问该文档");

        // 5. 返回文件流
        var (documentBytes, contentType) = await _apptDocumentService.DownloadDocument(appointmentId, documentId);
        return File(documentBytes, contentType);
    }
    catch (ArgumentException ex)
    {
        return HandleUserException(ex);
    }
    catch (Exception ex)
    {
        return HandleOtherException(ex);
    }
}
各方案优缺点
  • 方案1:安全性最高,链接过期自动失效,即使泄露也仅能被绑定用户在有效期内访问,支持跨设备场景
  • 方案2:实现最简单,完全依赖现有登录体系,适合Web端,但用户需跳转登录,体验稍差
  • 方案3:用户体验最好,无需额外操作,但仅适用于用户在同一设备登录过的场景,移动端App兼容性差

内容的提问来源于stack exchange,提问作者WAQ

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.19 09:10:35