Use the [Authorize]-attribute on Swagger UI with ASP.NET Core Swashbuckle

Viewed 5208

In ASP.NET Core, using Swashbuckle.AspNetCore, how do I protect the access to my Swagger UI in the same way as decorating it with the [Authorize]-attribute?

I want the (equivalent of) [Authorize]-attribute to execute, like for a normally decorated controller/action, when someone tries to access the /swagger-URL on my web-app, so that my custom AuthenticationHandler<T> is executed.

4 Answers

You could achieve this with a simple middleware solution

Middleware

public class SwaggerAuthenticationMiddleware : IMiddleware
{
    //CHANGE THIS TO SOMETHING STRONGER SO BRUTE FORCE ATTEMPTS CAN BE AVOIDED
    private const string UserName = "TestUser1";
    private const string Password = "TestPassword1";

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        //If we hit the swagger locally (in development) then don't worry about doing auth
        if (context.Request.Path.StartsWithSegments("/swagger") && !IsLocalRequest(context))
        {
            string authHeader = context.Request.Headers["Authorization"];
            if (authHeader != null && authHeader.StartsWith("Basic "))
            {
                // Get the encoded username and password
                var encodedUsernamePassword = authHeader.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries)[1]?.Trim();

                // Decode from Base64 to string
                var decodedUsernamePassword = Encoding.UTF8.GetString(Convert.FromBase64String(encodedUsernamePassword));

                // Split username and password
                var username = decodedUsernamePassword.Split(':', 2)[0];
                var password = decodedUsernamePassword.Split(':', 2)[1];

                // Check if login is correct
                if (IsAuthorized(username, password))
                {
                    await next.Invoke(context);
                    return;
                }
            }

            // Return authentication type (causes browser to show login dialog)
            context.Response.Headers["WWW-Authenticate"] = "Basic";

            // Return unauthorized
            context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
        }
        else
        {
            await next.Invoke(context);
        }
    }

    private bool IsAuthorized(string username, string password) => UserName == username && Password == password;

    private bool IsLocalRequest(HttpContext context)
    {
        if(context.Request.Host.Value.StartsWith("localhost:"))
            return true;

        //Handle running using the Microsoft.AspNetCore.TestHost and the site being run entirely locally in memory without an actual TCP/IP connection
        if (context.Connection.RemoteIpAddress == null && context.Connection.LocalIpAddress == null)
            return true;

        if (context.Connection.RemoteIpAddress != null && context.Connection.RemoteIpAddress.Equals(context.Connection.LocalIpAddress))
            return true;

        return IPAddress.IsLoopback(context.Connection.RemoteIpAddress);
    }
}

In startup -> Configure (make sure you add swagger stuff after authentication and authorization)

        app.UseAuthentication();
        app.UseAuthorization();

        //Enable Swagger and SwaggerUI
        app.UseMiddleware<SwaggerAuthenticationMiddleware>(); //can turn this into an extension if you ish
        app.UseSwagger();
        app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "my test api"));

In Startup -> ConfigureServices register the middleware

services.AddTransient<SwaggerAuthenticationMiddleware>();

The Swagger Middleware is fully independent from the MVC pipeline, so it's not possible out of the box. However, with a bit of reverse engineering, I found a workaround. It involves re-implementing most of the middleware in a custom controller, so it's a bit involved, and obviously it can break with a future update.

First, we need to stop calling IApplicationBuilder.UseSwagger and IApplicationBuilder.UseSwaggerUI, so that it doesn't conflict with our controller.

Then, we must add everything that was added by those methods by modifying our Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("documentName", new Info { Title = "My API", Version = "v1" });
    });

    // RouteTemplate is no longer used (route will be set via the controller)
    services.Configure<SwaggerOptions>(c =>
    {
    });

    // RoutePrefix is no longer used (route will be set via the controller)
    services.Configure<SwaggerUIOptions>(c =>
    {
        // matches our controller route
        c.SwaggerEndpoint("/swagger/documentName/swagger.json", "My API V1");
    });
}


public void Configure(IApplicationBuilder app)
{
    // we need a custom static files provider for the Swagger CSS etc..
    const string EmbeddedFileNamespace = "Swashbuckle.AspNetCore.SwaggerUI.node_modules.swagger_ui_dist";
    app.UseStaticFiles(new StaticFileOptions
    {
        RequestPath = "/swagger", // must match the swagger controller name
        FileProvider = new EmbeddedFileProvider(typeof(SwaggerUIMiddleware).GetTypeInfo().Assembly, EmbeddedFileNamespace),
    });
}

Finally, there are two things to re-implement: the generation of the swagger.json file, and the generation of the swagger UI. We do this with a custom controller:

[Authorize]
[Route("[controller]")]
public class SwaggerController : ControllerBase
{
    [HttpGet("{documentName}/swagger.json")]
    public ActionResult<string> GetSwaggerJson([FromServices] ISwaggerProvider swaggerProvider, 
        [FromServices] IOptions<SwaggerOptions> swaggerOptions, [FromServices] IOptions<MvcJsonOptions> jsonOptions,
        [FromRoute] string documentName)
    {
        // documentName is the name provided via the AddSwaggerGen(c => { c.SwaggerDoc("documentName") })
        var swaggerDoc = swaggerProvider.GetSwagger(documentName);

        // One last opportunity to modify the Swagger Document - this time with request context
        var options = swaggerOptions.Value;
        foreach (var filter in options.PreSerializeFilters)
        {
            filter(swaggerDoc, HttpContext.Request);
        }

        var swaggerSerializer = SwaggerSerializerFactory.Create(jsonOptions);
        var jsonBuilder = new StringBuilder();
        using (var writer = new StringWriter(jsonBuilder))
        {
            swaggerSerializer.Serialize(writer, swaggerDoc);
            return Content(jsonBuilder.ToString(), "application/json");
        }
    }

    [HttpGet]
    [HttpGet("index.html")]
    public ActionResult<string> GetSwagger([FromServices] ISwaggerProvider swaggerProvider, [FromServices] IOptions<SwaggerUIOptions> swaggerUiOptions)
    {
        var options = swaggerUiOptions.Value;
        var serializer = CreateJsonSerializer();

        var indexArguments = new Dictionary<string, string>()
        {
            { "%(DocumentTitle)", options.DocumentTitle },
            { "%(HeadContent)", options.HeadContent },
            { "%(ConfigObject)", SerializeToJson(serializer, options.ConfigObject) },
            { "%(OAuthConfigObject)", SerializeToJson(serializer, options.OAuthConfigObject) }
        };

        using (var stream = options.IndexStream())
        {
            // Inject arguments before writing to response
            var htmlBuilder = new StringBuilder(new StreamReader(stream).ReadToEnd());
            foreach (var entry in indexArguments)
            {
                htmlBuilder.Replace(entry.Key, entry.Value);
            }

            return Content(htmlBuilder.ToString(), "text/html;charset=utf-8");
        }
    }

    private JsonSerializer CreateJsonSerializer()
    {
        return JsonSerializer.Create(new JsonSerializerSettings
        {
            ContractResolver = new CamelCasePropertyNamesContractResolver(),
            Converters = new[] { new StringEnumConverter(true) },
            NullValueHandling = NullValueHandling.Ignore,
            Formatting = Formatting.None,
            StringEscapeHandling = StringEscapeHandling.EscapeHtml
        });
    }

    private string SerializeToJson(JsonSerializer jsonSerializer, object obj)
    {
        var writer = new StringWriter();
        jsonSerializer.Serialize(writer, obj);
        return writer.ToString();
    }
}

Well, i found an easy solution to the problem. You need to do the following:

  • Implement a middleware. If you have an existing one, you can use that.
  • app.UseSwagger() should be called after app.UseAuthentication().
  • In the middleware Invoke method, just check the path for swagger and redirect the user to the home page/other pages under layout, etc which has authentication enabled or just write a message like "Not authorized" and return.
  • This is far better than an attribute since you can stop the user before reaching the controller and it can easily be scaled to all the controllers.

Hope this helps.

I was able to enhance @Ricky G's answer to support asp.net core identity authentication mechanism.

In SwaggerAuthenticationMiddleware ,

public async Task InvokeAsync(HttpContext context)
{
    //Make sure we are hitting the swagger path, and not doing it locally as it just gets annoying :-)
    if (context.Request.Path.StartsWithSegments("/swagger"))
    {
        if (!context.User.Identity.IsAuthenticated)
        {
            string authHeader = context.Request.Headers["Authorization"];
            if (authHeader != null && authHeader.StartsWith("Basic "))
            {
                // Get the encoded username and password
                var encodedUsernamePassword = authHeader.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries)[1]?.Trim();

                // Decode from Base64 to string
                var decodedUsernamePassword = Encoding.UTF8.GetString(Convert.FromBase64String(encodedUsernamePassword));

                // Split username and password
                var username = decodedUsernamePassword.Split(':', 2)[0];
                var password = decodedUsernamePassword.Split(':', 2)[1];

                var signInManager = _httpContextAccessor.HttpContext.RequestServices.GetService<SignInManager<IdpUser>>();
                var result = await signInManager.PasswordSignInAsync(username, password, false, lockoutOnFailure: false);
                if (result.Succeeded)
                {
                    await next.Invoke(context);
                    return;
                }
            }

            // Return authentication type (causes browser to show login dialog)
            context.Response.Headers["WWW-Authenticate"] = "Basic";

            // Return unauthorized
            context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
        }
        else
        {
            await next.Invoke(context);
            return;
        }
    }
    else
    {
        await next.Invoke(context);
    }
}

In Startup.cs you have to register HttpContextAccessor like below.

services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
Related