Protect static files with ASP.NET Core

security workflow identity server wallpaper

In this new post I explain how to protect static file with ASP.NET Core and IdentityServer 4 using Razor Pages deployed on Azure. This example is working with all kind of files apert from HTML and CSS files.

A few weeks ago, I started a new ASP.NET Core web application project to protect static files with authorization on that application, but I had no idea how to implement it at that time.

After a quick search on internet, I found some recommendation and I think I want to try to one of the following solutions:

  1. Insert ASP.NET Core middleware that your custom implementation into HTTP process pipeline at the before of static files middleware and reject requests that aren’t authorized.
  2. Place static files you want to authorize to outside of wwwroot, and serve it by ASP.NET Core MVC controller that your custom implementation, instead as explained by Microsoft official document site

However, I couldn’t use those solutions for some reasons specified with the constraint of this project.

So, I continued reading the search results, and finally, I found the solution that I prefer.

The solution that I prefer is, hook the OnPrepareResponse call back point of static files middleware.

Integration with IdentityServer

So, how protect static file with ASP.NET Core and Identity Server 4 starts with user authentication. In my previous post titled “Implement security workflow with Identity Server” I gave you an overview of what security with IdentityServer means for internal and external applications web based or console.

For that, I assume you have your IdentityServer somewhere and you have created a new client for this application, so, you have clientId and clientSecret. In my demo that you can download from GitHub, you have to insert your details in the appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "IdentityServerConfiguration": {
    "Url": "yoururl",
    "ClientId": "yourclientid",
    "ClientSecret": "yourclientsecret"
  }
}

After that, the Startup.cs should be ready to accept the requests.

public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages(options => {
        options.Conventions.AuthorizePage("/Login");
    });

    services.Configure<IdentityServerConfiguration>(
             Configuration.GetSection("IdentityServerConfiguration"));

    services.AddDistributedMemoryCache();

    services.AddSession(options =>
    {
        options.Cookie.Name = ".psc.Session";
        options.IdleTimeout = TimeSpan.FromHours(12);
    });

    services.AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = "oidc";
    })
    .AddCookie(options =>
    {
        options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
        options.Cookie.Name = "psc.dashboard";
    })
    .AddOpenIdConnect("oidc", options =>
    {
        IdentityServerConfiguration idsrv = Configuration
             .GetSection("IdentityServerConfiguration")
             .Get<IdentityServerConfiguration>();
        options.Authority = idsrv.Url;
        options.ClientId = idsrv.ClientId;
        options.ClientSecret = idsrv.ClientSecret;

#if DEBUG
        options.RequireHttpsMetadata = false;
#else
        options.RequireHttpsMetadata = true;
#endif

        options.ResponseType = "code";

        options.Scope.Clear();
        options.Scope.Add("openid");
        options.Scope.Add("profile");
        options.Scope.Add("email");
        options.Scope.Add("roles");
        options.Scope.Add("offline_access");

        options.ClaimActions.MapJsonKey("role", "role", "role");

        options.GetClaimsFromUserInfoEndpoint = true;
        options.SaveTokens = true;

        options.SignedOutRedirectUri = "/";

        options.TokenValidationParameters = new TokenValidationParameters
        {
            NameClaimType = JwtClaimTypes.Name,
            RoleClaimType = JwtClaimTypes.Role,
        };
    });
}

Then, the integration with IdentityServer is set but we have to add a mechanism for the login and the logout. The project is based on Razor Pages. The idea is just to add two pages:

  • Login page to force the application to redirect the user to the IdentityServer. Where the application receives the validation, the page has to redirect the user to the home page (for example)
  • Logout page to remove the cookies created for the authentication

To add authorization to the Login page, we have to change the Startup.cs. At the beginning of the ConfigureServices I added this code:

services.AddRazorPages(options => {
    options.Conventions.AuthorizePage("/Login");
});

After that, for the Logout page, we have to add the code to remove the cookies.

public async Task<IActionResult> OnGet()
{
    await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    await HttpContext.SignOutAsync("oidc");
    return Redirect("/");
}

Static Files middleware

Thinking about how to protect static file with ASP.NET Core and Identity Server 4, I started to look better to the middleware.

What is a middleware?

Middleware is software that’s assembled into an app pipeline to handle requests and responses. Each component:

  • Chooses whether to pass the request to the next component in the pipeline.
  • Can perform work before and after the next component in the pipeline.

Request delegates are used to build the request pipeline. The request delegates handle each HTTP request.

Request delegates are configured using RunMap, and Use extension methods. An individual request delegate can be specified in-line as an anonymous method (called in-line middleware), or it can be defined in a reusable class. These reusable classes and in-line anonymous methods are middleware, also called middleware components. Each middleware component in the request pipeline is responsible for invoking the next component in the pipeline or short-circuiting the pipeline. When a middleware short-circuits, it’s called a terminal middleware because it prevents further middleware from processing the request.

Request processing pattern showing a request arriving, processing through three middlewares, and the response leaving the app. Each middleware runs its logic and hands off the request to the next middleware at the next() statement. After the third middleware processes the request, the request passes back through the prior two middlewares in reverse order for additional processing after their next() statements before leaving the app as a response to the client.
ASP.NET Core Middleware

Use StaticFileOptions

When we register the “Static Files” middleware built-in ASP.NET Core into the HTTP process pipeline, we can also specify a StaticFileOptions option argument.

StaticFileOptions class has the property of a good hook point that allows us to insert a process before serving static files.

That property name is OnPrepareResponse.

We can set a call back function to the OnPrepareResponse property, then that function will be called back before serving each static file, and we can change the response rely on authorization state!

So, we have to register “Authentication” middleware at the before of “Static Files” middleware to be available detect authenticated or not.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
  ...
  app.UseAuthentication();
  app.UseStaticFiles(new StaticFileOptions
  {
    OnPrepareResponse = ctx =>
    {
      if (!ctx.Context.User.Identity.IsAuthenticated)
      {
        // respond HTTP 401 Unauthorized.
        ctx.Context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
      }
      ...

My call back function exactly returned the “401 Unauthorized” HTTP status to the browser, but it didn’t stop the response body!

ASP.NET Core blocks the file
ASP.NET Core blocks the file

I have to not only return HTTP 401 but stop the entire of responding.

To do this, I appended 2 lines in my call back function like this:

// respond HTTP 401 Unauthorized, and...
ctx.Context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;

// Append following 2 lines to drop body from static files middleware!
ctx.Context.Response.ContentLength = 0;
ctx.Context.Response.Body = Stream.Null;

This code drops the writing to the stream of response body from “Static Files” middleware, because that code replaces the body stream to System.Null.

The all of contents that wrote into System.Null is discarded, and it doesn’t cause any effects.

Finally, I could protect the secret static files.

This page isn't working
This page isn’t working

Please remember, to protect those secret static files, we have to concern browser caches.

In some cases, I could see the secret file from browser cache even if I wasn’t authenticated after signed out from the application.

I avoided this problem by adding the “Cache-Control” header to the response.

ctx.Context.Response.Headers.Add("Cache-Control", "no-store");

If you want to redirect to another page such as “Sign in” page instead of returning “HTTP 401”, yes, you can do it by like this code:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
  ...
  app.UseAuthentication();
  app.UseStaticFiles(new StaticFileOptions
  {
    OnPrepareResponse = ctx =>
    {
      if (!ctx.Context.User.Identity.IsAuthenticated)
      {
        // Can redirect to any URL where you prefer.
        ctx.Context.Response.Redirect("/")
      }
      ...

Conclusion

At the end of this post about how to protect static files with ASP.NET Core, my conclusions are:

  • We can protect static files with authorization on the ASP.NET Core web application by using the OnPrepareResponse property of the options argument for “Static Files” middleware.
  • Don’t forget that place the calling UseAuthentication() at before of the calling UseStaticFiles(...).
  • We have to drop the entire of the response body from “Static Files” middleware when the request is unauthorized.
  • Please consider cache control to protect static files perfectly.
  • We can also redirect to another page such as “Sign in” page instead of returning “HTTP 401”.

The entire of my sample code is public on the GitHub repository.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.