在企業開發中,咱們常常會遇到由用戶上傳文件的場景,好比某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檢測文件真實格式