在企业开发中, 我们经常会遇到由用户上传文件的场景, 比如某 OA 系统中, 由用户填写某表单并上传身份证, 由身份管理员审查, 超级管理员可以查看.
就这样一个场景, 用户上传的文件只能有三种人看得见(能够访问)
上传文件的人
身份审查人员
超级管理员
那么, 这篇博客中我们将一起学习如何设计并实现一款文件授权中间件
问题分析
如何判断文件属于谁
要想文件能够被授权, 文件的命名就要有规律, 我们可以从文件命名中确定文件是属于谁的, 例如本文例可以设计文件名为这样
工号 - GUID-[Front/Back]
例如:
100211-4738B54D3609410CBC785BCD1963F3FA-Front
, 这代表由 100211 上传的身份证正面
判断文件属于哪个功能
一个企业系统中上传文件的功能可能有很多:
某个功能中上传身份证
某个功能中上传合同
某个功能上传发票
我们的区分方式是使用路径, 例如本文例使用
- /id-card
- /contract
- /invoices
不能通过 StaticFile 中间件访问
由 StaticFile 中间件处理的文件都是公开的, 由这个中间件处理的文件只能是公开的 js,css,image 等等可以由任何人访问的文件
设计与实现
为什么使用中间件实现
对于我们的需求, 我们还可以使用 Controller/Action 直接实现, 这样比较简单, 但是难以复用, 想要在其它项目中使用只能复制代码.
使用独立的文件存储目录
在本文例中我们将所有的文件 (无论来自哪个上传功能) 都放在一个根目录下例如: C:\xxx-uploads(windows), 这个目录不由 StaticFile 中间件管控
中间件结构设计
这是一个典型的 Service-Handler 模式, 当请求到达文件授权中间件时, 中间件让
FileAuthorizationService
根据请求特征确定该请求属于的 Handler, 并执行授权授权任务, 获得授权结果, 文件授权中间件根据授权结果来确定向客户端返回文件还是返回其它未授权结果.
请求特征设计
只有请求是特定格式时才会进入到文件授权中间件, 例如我们将其设计为这样
host / 中间件标记 / handler 标记 / 文件标记
那么对应的请求就可能是:
https://localhost:8080/files/id-card/100211-4738B54D3609410CBC785BCD1963F3FA-Front.jpg
这里面 files 是作用于中间件的标记, id-card 用于确认由 IdCardHandler 处理, 后面的内容用于确认上传者的身份
IFileAuthorizationService 设计
- public interface IFileAuthorizationService
- {
- string AuthorizationScheme { get; }
- string FileRootPath { get; }
- Task<FileAuthorizeResult> AuthorizeAsync(HttpContext context, string path);
这里的
AuthorizationScheme
对应, 上文中的中间件标记, FileRootPath 代表文件根目录的绝对路径, AuthorizeAsync 方法则用于切实的认证, 并返回一个认证的结果
FileAuthorizeResult 设计
- public class FileAuthorizeResult
- {
- public bool Succeeded { get; }
- public string RelativePath { get; }
- public string FileDownloadName { get; set; }
- public Exception Failure { get; }
Succeeded 指示授权是否成功
RelativePath 文件的相对路径, 请求中的文件可能会映射成完全不同的文件路径, 这样更加安全例如将 Uri
/files/id-card/4738B54D3609410CBC785BCD1963F3FA.jpg
映射到
/xxx-file/abc/100211-4738B54D3609410CBC785BCD1963F3FA-Front.jpg
, 这样做可以混淆请求中的文件名, 更加安全
FileDownloadName 文件下载的名称, 例如上例中文件命中可能包含工号, 而下载时可以仅仅是一个 GUID
Failure 授权是发生的错误, 或者错误原因
IFileAuthorizeHandler 设计
- public interface IFileAuthorizeHandler
- {
- Task<FileAuthorizeResult> AuthorizeAsync(HttpContext context,string path);
略...
IFileAuthorizeHandler 只要求有一个方法, 即授权的方法
IFileAuthorizationHandlerProvider 设计
- public interface IFileAuthorizationHandlerProvider
- {
- Type GetHandlerType (string scheme);
- bool Exist(string scheme);
略...
GetHandlerType 用于获取指定 AuthorizeHandler 的实际类型, 在 AuthorizationService 中会使用此方法
Exist 方法用于确认是否含有指定的处理器
FileAuthorizationOptions 设计
- public class FileAuthorizationOptions
- {
- private List<FileAuthorizationScheme> _schemes = new List<FileAuthorizationScheme>(20);
- public string FileRootPath { get; set; }
- public string AuthorizationScheme { get; set; }
- public IEnumerable<FileAuthorizationScheme> Schemes { get => _schemes; }
- public void AddHandler<THandler>(string name) where THandler : IFileAuthorizeHandler
- {
- _schemes.Add(new FileAuthorizationScheme(name, typeof(THandler)));
- }
- public Type GetHandlerType(string scheme)
- {
- return _schemes.Find(s => s.Name == scheme)?.HandlerType;
略...
FileAuthorizationOptions 的主要责任是确认相关选项, 例如: FileRootPath 和 AuthorizationScheme. 以及存储 handler 标记与 Handler 类型的映射.
上一小节中 IFileAuthorizationHandlerProvider 是用于提供 Handler 的, 那么为什么要将存储放在 Options 里呢?
原因如下:
Provider 只负责提供, 而存储可能不由它负责
未来存储可能更换, 但是调用 Provider 的组件或代码并不关心
就现在的需求来说这样实现比较方便, 且没有什么问题
FileAuthorizationScheme 设计
- public class FileAuthorizationScheme
- {
- public FileAuthorizationScheme(string name, Type handlerType)
- {
- if (string.IsNullOrEmpty(name))
- {
- throw new ArgumentException("name must be a valid string.", nameof(name));
- }
- Name = name;
- HandlerType = handlerType ?? throw new ArgumentNullException(nameof(handlerType));
- }
- public string Name { get; }
- public Type HandlerType { get; }
略...
这个类的功能就是存储 handler 标记与 Handler 类型的映射
FileAuthorizationService 实现
第一部分是 AuthorizationScheme 和 FileRootPath
- public class FileAuthorizationService : IFileAuthorizationService
- {
- public FileAuthorizationOptions Options { get; }
- public IFileAuthorizationHandlerProvider Provider { get; }
- public string AuthorizationScheme => Options.AuthorizationScheme;
- public string FileRootPath => Options.FileRootPath;
最重要的部分是 授权方法的实现:
- public async Task<FileAuthorizeResult> AuthorizeAsync(HttpContext context, string path)
- {
- var handlerScheme = GetHandlerScheme(path);
- if (handlerScheme == null || !Provider.Exist(handlerScheme))
- {
- return FileAuthorizeResult.Fail();
- }
- var handlerType = Provider.GetHandlerType(handlerScheme);
- if (!(context.RequestServices.GetService(handlerType) is IFileAuthorizeHandler handler))
- {
- throw new Exception($"the required file authorization handler of'{handlerScheme}'is not found");
- }
- // start with slash
- var requestFilePath = GetRequestFileUri(path, handlerScheme);
- return await handler.AuthorizeAsync(context, requestFilePath);
- }
授权过程总共分三步:
获取当前请求映射的 handler 类型
向 Di 容器获取 handler 的实例
由 handler 进行授权
这里给出代码片段中用到的两个私有方法:
- private string GetHandlerScheme(string path)
- {
- var arr = path.Split('/');
- if (arr.Length <2)
- {
- return null;
- }
- // arr[0] is the Options.AuthorizationScheme
- return arr[1];
- }
- private string GetRequestFileUri(string path, string scheme)
- {
- return path.Remove(0, Options.AuthorizationScheme.Length + scheme.Length + 1);
- }
FileAuthorization 中间件设计与实现
由于授权逻辑已经提取到
IFileAuthorizationService
和
IFileAuthorizationHandler
中, 所以中间件所负责的功能就很少, 主要是接受请求和向客户端写入文件.
理解接下来的内容需要中间件知识, 如果你并不熟悉中间件那么请先学习中间件
你可以参看 ASP.NET Core 中间件 https://docs.microsoft.com/zh-cn/aspnet/core/fundamentals/middleware/?view=aspnetcore-2.1&tabs=aspnetcore2x 文档进行学习
接下来我们先贴出完整的 Invoke 方法, 再逐步解析:
- public async Task Invoke(HttpContext context)
- {
- // trim the start slash
- var path = context.Request.Path.Value.TrimStart('/');
- if (!BelongToMe(path))
- {
- await _next.Invoke(context);
- return;
- }
- var result = await _service.AuthorizeAsync(context, path);
- if (!result.Succeeded)
- {
- _logger.LogInformation($"request file is forbidden. request path is: {path}");
- Forbidden(context);
- return;
- }
- if (string.IsNullOrWhiteSpace(_service.FileRootPath))
- {
- throw new Exception("file root path is not spicificated");
- }
- string fullName;
- if (Path.IsPathRooted(result.RelativePath))
- {
- fullName = result.RelativePath;
- }
- else
- {
- fullName = Path.Combine(_service.FileRootPath, result.RelativePath);
- }
- var fileInfo = new FileInfo(fullName);
- if (!fileInfo.Exists)
- {
- NotFound(context);
- return;
- }
- _logger.LogInformation($"{context.User.Identity.Name} request file :{fileInfo.FullName} has beeb authorized. File sending");
- SetResponseHeaders(context, result, fileInfo);
- await WriteFileAsync(context, result, fileInfo);
- }
第一步是获取请求的 Url 并且判断这个请求是否属于当前的文件授权中间件
- var path = context.Request.Path.Value.TrimStart('/');
- if (!BelongToMe(path))
- {
- await _next.Invoke(context);
- return;
- }
判断的方式是检查 Url 中的第一段是不是等于 AuthorizationScheme(例如: files)
- private bool BelongToMe(string path)
- {
- return path.StartsWith(_service.AuthorizationScheme, true, CultureInfo.CurrentCulture);
- }
第二步是调用
IFileAuthorizationService
进行授权
var result = await _service.AuthorizeAsync(context, path);
第三步是对结果进行处理, 如果失败了就阻止文件的下载:
- if (!result.Succeeded)
- {
- _logger.LogInformation($"request file is forbidden. request path is: {path}");
- Forbidden(context);
- return;
- }
阻止的方式是返回 403, 未授权的 HttpCode
- private void Forbidden(HttpContext context)
- {
- HttpCode(context, 403);
- }
- private void HttpCode(HttpContext context, int code)
- {
- context.Response.StatusCode = code;
- }
如果成功则, 向响应中写入文件:
写入文件相对前面的逻辑稍稍复杂一点, 但其实也很简单, 我们一起来看一下
第一步, 确认文件的完整路径:
- string fullName;
- if (Path.IsPathRooted(result.RelativePath))
- {
- fullName = result.RelativePath;
- }
- else
- {
- fullName = Path.Combine(_service.FileRootPath, result.RelativePath);
- }
前文提到, 我们设计的是将文件全部存储到一个目录下, 但事实上我们不这样做也可以, 只要负责授权的 handler 将请求映射成完整的物理路径就行, 这样, 在未来就有更多的扩展性, 比如某功能的文件没有存储在统一的目录下, 那么也可以.
这一步就是判断和确认最终的文件路径
第二步, 检查文件是否存在:
- var fileInfo = new FileInfo(fullName);
- if (!fileInfo.Exists)
- {
- NotFound(context);
- return;
- }
- private void NotFound(HttpContext context)
- {
- HttpCode(context, 404);
- }
最后一步写入文件:
await WriteFileAsync(context, result, fileInfo);
完整方法如下:
- private async Task WriteFileAsync(HttpContext context, FileAuthorizeResult result, FileInfo fileInfo)
- {
- var response = context.Response;
- var sendFile = response.HttpContext.Features.Get<IHttpSendFileFeature>();
- if (sendFile != null)
- {
- await sendFile.SendFileAsync(fileInfo.FullName, 0L, null, default(CancellationToken));
- return;
- }
- using (var fileStream = new FileStream(
- fileInfo.FullName,
- FileMode.Open,
- FileAccess.Read,
- FileShare.ReadWrite,
- BufferSize,
- FileOptions.Asynchronous | FileOptions.SequentialScan))
- {
- try
- {
- await StreamCopyOperation.CopyToAsync(fileStream, context.Response.Body, count: null, bufferSize: BufferSize, cancel: context.RequestAborted);
- }
- catch (OperationCanceledException)
- {
- // Don't throw this exception, it's most likely caused by the client disconnecting.
- // However, if it was cancelled for any other reason we need to prevent empty responses.
- context.Abort();
首先我们是先请求了
IHttpSendFileFeature
, 如果有的话直接使用它来发送文件
- var sendFile = response.HttpContext.Features.Get<IHttpSendFileFeature>();
- if (sendFile != null)
- {
- await sendFile.SendFileAsync(fileInfo.FullName, 0L, null, default(CancellationToken));
- return;
- }
这是 Asp.Net Core 中的另一重要功能, 如果你不了解它你可以不用太在意, 因为此处影响不大, 不过如果你想学习它, 那么你可以参考 ASP.NET Core 中的请求功能 https://docs.microsoft.com/zh-cn/aspnet/core/fundamentals/request-features?view=aspnetcore-2.1 文档
如果, 不支持
IHttpSendFileFeature
那么就使用原始的方法将文件写入请求体:
- using (var fileStream = new FileStream(
- fileInfo.FullName,
- FileMode.Open,
- FileAccess.Read,
- FileShare.ReadWrite,
- BufferSize,
- FileOptions.Asynchronous | FileOptions.SequentialScan))
- {
- try
- {
- await StreamCopyOperation.CopyToAsync(fileStream, context.Response.Body, count: null, bufferSize: BufferSize, cancel: context.RequestAborted);
- }
- catch (OperationCanceledException)
- {
- // Don't throw this exception, it's most likely caused by the client disconnecting.
- // However, if it was cancelled for any other reason we need to prevent empty responses.
- context.Abort();
到此处, 我们的中间件就完成了.
中间件的扩展方法
虽然我们的中间件和授权服务都写完了, 但是似乎还不能直接用, 所以接下来我们来编写相关的扩展方法, 让其切实的运行起来
最终的使用效果类似这样:
- // 在 di 配置中
- services.AddFileAuthorization(options =>
- {
- options.AuthorizationScheme = "file";
- options.FileRootPath = CreateFileRootPath();
- })
- .AddHandler<TestHandler>("id-card");
- // 在管道配置中
- app.UseFileAuthorization();
要达到上述效果要编写三个类:
- FileAuthorizationBuilder
- FileAuthorizationAppBuilderExtentions
- FileAuthorizationServiceCollectionExtensions
地二个用于实现
app.UseFileAuthorization();
第三个用于实现
services.AddFileAuthorization(options =>...
第一个用于实现
- .AddHandler<TestHandler>("id-card");
- FileAuthorizationBuilder
- public class FileAuthorizationBuilder
- {
- public FileAuthorizationBuilder(IServiceCollection services)
- {
- Services = services;
- }
- public IServiceCollection Services { get; }
- public FileAuthorizationBuilder AddHandler<THandler>(string name) where THandler : class, IFileAuthorizeHandler
- {
- Services.Configure<FileAuthorizationOptions>(options =>
- {
- options.AddHandler<THandler>(name );
- });
- Services.AddTransient<THandler>();
- return this;
这部分主要作用是实现添加 handler 的方法, 添加的 handler 是瞬时的
- FileAuthorizationAppBuilderExtentions
- public static class FileAuthorizationAppBuilderExtentions
- {
- public static IApplicationBuilder UseFileAuthorization(this IApplicationBuilder app)
- {
- if (app == null)
- {
- throw new ArgumentNullException(nameof(app));
- }
- return app.UseMiddleware<FileAuthenticationMiddleware>();
这个主要作用是将中间件放入管道, 很简单
- FileAuthorizationServiceCollectionExtensions
- public static class FileAuthorizationServiceCollectionExtensions
- {
- public static FileAuthorizationBuilder AddFileAuthorization(this IServiceCollection services)
- {
- return AddFileAuthorization(services, null);
- }
- public static FileAuthorizationBuilder AddFileAuthorization(this IServiceCollection services, Action<FileAuthorizationOptions> setup)
- {
- services.AddSingleton<IFileAuthorizationService, FileAuthorizationService>();
- services.AddSingleton<IFileAuthorizationHandlerProvider, FileAuthorizationHandlerProvider>();
- if (setup != null)
- {
- services.Configure(setup);
- }
- return new FileAuthorizationBuilder(services);
这部分是注册服务, 将
IFileAuthorizationService
和
IFileAuthorizationService
注册为单例
到这里, 所有的代码就完成了
测试
我们来编写个简单的测试来测试中间件的运行效果
要先写一个测试用的 Handler, 这个 Handler 允许任何用户访问文件:
- public class TestHandler : IFileAuthorizeHandler
- {
- public const string TestHandlerScheme = "id-card";
- public Task<FileAuthorizeResult> AuthorizeAsync(HttpContext context, string path)
- {
- return Task.FromResult(FileAuthorizeResult.Success(GetRelativeFilePath(path), GetDownloadFileName(path)));
- }
- public string GetRelativeFilePath(string path)
- {
- path = path.TrimStart('/', '\\').Replace('/', '\\');
- return $"{TestHandlerScheme}\\{path}";
- }
- public string GetDownloadFileName(string path)
- {
- return path.Substring(path.LastIndexOf('/') + 1);
- }
- }
测试方法:
- public async Task InvokeTest()
- {
- var builder = new WebHostBuilder()
- .Configure(app =>
- {
- app.UseFileAuthorization();
- })
- .ConfigureServices(services =>
- {
- services.AddFileAuthorization(options =>
- {
- options.AuthorizationScheme = "file";
- options.FileRootPath = CreateFileRootPath();
- })
- .AddHandler<TestHandler>("id-card");
- });
- var server = new TestServer(builder);
- var response = await server.CreateClient().GetAsync("http://example.com/file/id-card/front.jpg");
- Assert.Equal(200, (int)response.StatusCode);
- Assert.Equal("image/jpeg", response.Content.Headers.ContentType.MediaType);
- }
这个测试如期通过, 本例中还写了其它诸多测试, 就不一一贴出了, 另外, 这个项目目前已上传到我的 github 上了, 需要代码的同学自取
https://github.com/rocketRobin/FileAuthorization
你也可以直接使用 Nuget 获取这个中间件:
- Install-Package FileAuthorization https://www.nuget.org/packages/FileAuthorization/
- Install-Package FileAuthorization.Abstractions https://www.nuget.org/packages/FileAuthorization/
如果这篇文章对你有用, 那就给我点个赞吧: D
来源: https://www.cnblogs.com/rocketRobin/p/9334780.html