天天看點

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

在企業開發中,我們經常會遇到由使用者上傳檔案的場景,比如某OA系統中,由使用者填寫某表單并上傳身份證,由身份管理者審查,超級管理者可以檢視。

就這樣一個場景,使用者上傳的檔案隻能有三種人看得見(能夠通路)

  • 上傳檔案的人
  • 身份審查人員
  • 超級管理者

那麼,這篇部落格中我們将一起學習如何設計并實作一款檔案授權中間件

問題分析

如何判斷檔案屬于誰

要想檔案能夠被授權,檔案的命名就要有規律,我們可以從檔案命名中确定檔案是屬于誰的,例如本文例可以設計檔案名為這樣

工号-GUID-[Front/Back]
           

例如:

100211-4738B54D3609410CBC785BCD1963F3FA-Front

,這代表由100211上傳的身份證正面

判斷檔案屬于哪個功能

一個企業系統中上傳檔案的功能可能有很多:

  • 某個功能中上傳身份證
  • 某個功能中上傳合同
  • 某個功能上傳發票

我們的區分方式是使用路徑,例如本文例使用

  • /id-card
  • /contract
  • /invoices

不能通過StaticFile中間件通路

由StaticFile中間件處理的檔案都是公開的,由這個中間件處理的檔案隻能是公開的js、css、image等等可以由任何人通路的檔案

設計與實作

為什麼使用中間件實作

對于我們的需求,我們還可以使用Controller/Action直接實作,這樣比較簡單,但是難以複用,想要在其它項目中使用隻能複制代碼。

使用獨立的檔案存儲目錄

在本文例中我們将所有的檔案(無論來自哪個上傳功能)都放在一個根目錄下例如:C:xxx-uploads(windows),這個目錄不由StaticFile中間件管控

中間件結構設計

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

這是一個典型的 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中間件設計與實作

由于授權邏輯已經提取到

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

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

  • FileAuthorizationBuilder
  • FileAuthorizationAppBuilderExtentions
  • FileAuthorizationServiceCollectionExtensions

地二個用于實作

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檢測檔案真實格式