在企业开发中,咱们常常会遇到由用户上传文件的场景,好比某OA系统中,由用户填写某表单并上传身份证,由身份管理员审查,超级管理员能够查看。css
就这样一个场景,用户上传的文件只能有三种人看得见(可以访问)html
那么,这篇博客中咱们将一块儿学习如何设计并实现一款文件受权中间件git
要想文件可以被受权,文件的命名就要有规律,咱们能够从文件命名中肯定文件是属于谁的,例如本文例能够设计文件名为这样github
工号-GUID-[Front/Back]
例如: 100211-4738B54D3609410CBC785BCD1963F3FA-Front
,这表明由100211上传的身份证正面windows
一个企业系统中上传文件的功能可能有不少:安全
咱们的区分方式是使用路径,例如本文例使用app
由StaticFile中间件处理的文件都是公开的,由这个中间件处理的文件只能是公开的js、css、image等等能够由任何人访问的文件async
对于咱们的需求,咱们还可使用Controller/Action直接实现,这样比较简单,可是难以复用,想要在其它项目中使用只能复制代码。ide
在本文例中咱们将全部的文件(不管来自哪一个上传功能)都放在一个根目录下例如: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
处理,后面的内容用于确认上传者的身份
public interface IFileAuthorizationService { string AuthorizationScheme { get; } string FileRootPath { get; } Task<FileAuthorizeResult> AuthorizeAsync(HttpContext context, string path);
这里的 AuthorizationScheme
对应,上文中的中间件标记,FileRootPath
表明文件根目录的绝对路径,AuthorizeAsync
方法则用于切实的认证,并返回一个认证的结果
public class FileAuthorizeResult { public bool Succeeded { get; } public string RelativePath { get; } public string FileDownloadName { get; set; } public Exception Failure { get; }
/files/id-card/4738B54D3609410CBC785BCD1963F3FA.jpg
映射到/xxx-file/abc/100211-4738B54D3609410CBC785BCD1963F3FA-Front.jpg
,这样作能够混淆请求中的文件名,更加安全public interface IFileAuthorizeHandler { Task<FileAuthorizeResult> AuthorizeAsync(HttpContext context,string path); 略...
IFileAuthorizeHandler 只要求有一个方法,即受权的方法
public interface IFileAuthorizationHandlerProvider { Type GetHandlerType (string scheme); bool Exist(string scheme); 略...
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里呢?
缘由以下:
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类型的映射
第一部分是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); }
受权过程总共分三步:
这里给出代码片断中用到的两个私有方法:
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); }
因为受权逻辑已经提取到 IFileAuthorizationService
和IFileAuthorizationHandler
中,因此中间件所负责的功能就不多,主要是接受请求和向客户端写入文件。
理解接下来的内容须要中间件知识,若是你并不熟悉中间件那么请先学习中间件
你能够参看ASP.NET Core 中间件文档进行学习
接下来咱们先贴出完整的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 中的请求功能文档
若是,不支持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();
要达到上述效果要编写三个类:
地二个用于实现app.UseFileAuthorization();
第三个用于实现services.AddFileAuthorization(options =>...
第一个用于实现.AddHandler<TestHandler>("id-card");
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是瞬时的
public static class FileAuthorizationAppBuilderExtentions { public static IApplicationBuilder UseFileAuthorization(this IApplicationBuilder app) { if (app == null) { throw new ArgumentNullException(nameof(app)); } return app.UseMiddleware<FileAuthenticationMiddleware>();
这个主要做用是将中间件放入管道,很简单
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
Install-Package FileAuthorization.Abstractions
若是这篇文章对你有用,那就给我点个赞吧:D
欢迎转载,转载请注明原做者和出处,谢谢
最后最后,在企业开发中咱们还要检测用户上传文件的真实性,若是经过文件扩展名确认,显然不靠谱,因此咱们得用其它方法,若是你也有相关的问题,能够参考个人另一篇博客在.NetCore中使用Myrmec检测文件真实格式