邮件中文档下载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
相关产品推荐
相关产品推荐

