1. 程式人生 > >在Asp.Net Core中使用中間件保護非公開文件

在Asp.Net Core中使用中間件保護非公開文件

解析 seq const sha readwrite req image 上傳文件 res

在企業開發中,我們經常會遇到由用戶上傳文件的場景,比如某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裏呢?

原因如下:

  1. Provider只負責提供,而存儲可能不由它負責
  2. 未來存儲可能更換,但是調用Provider的組件或代碼並不關心
  3. 就現在的需求來說這樣實現比較方便,且沒有什麽問題

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);
}

授權過程總共分三步:

  1. 獲取當前請求映射的handler 類型
  2. 向Di容器獲取handler的實例
  3. 由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中間件設計與實現

由於授權邏輯已經提取到 IFileAuthorizationServiceIFileAuthorizationHandler中,所以中間件所負責的功能就很少,主要是接受請求和向客戶端寫入文件。

理解接下來的內容需要中間件知識,如果你並不熟悉中間件那麽請先學習中間件
你可以參看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();

要達到上述效果要編寫三個類:

  • 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);

這部分是註冊服務,將IFileAuthorizationServiceIFileAuthorizationService註冊為單例

到這裏,所有的代碼就完成了

測試

我們來編寫個簡單的測試來測試中間件的運行效果

要先寫一個測試用的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檢測文件真實格式

在Asp.Net Core中使用中間件保護非公開文件