12

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.

Seb Nilsson
  • 26,200
  • 30
  • 103
  • 130

4 Answers4

4

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>();
Ricky Gummadi
  • 4,559
  • 2
  • 41
  • 67
  • Interesting solution! Can your basic auth approached be combined with an existing user-based authentication? Coincidentally, I have a very similar question and might wonder whether your solution would work as well: https://stackoverflow.com/questions/62727471 – citronas Jul 09 '20 at 11:40
  • Doesn't fulfill the requirement of: "I want the ... [Authorize]-attribute to execute". Manual authentication is not a future-safe solution. – Seb Nilsson Jul 09 '20 at 14:29
  • Agreed currently there is no way to integrate the Swagger UI with the [Authorize] attribute with out significant work, even that wouldn't be future proof with the next release of Swashbuckle etc. This offers a simple way to block access to Swagger UI, spefically if you have public hosted API and the entire Swagger UI is open for viewing – Ricky Gummadi Jul 10 '20 at 09:16
  • Awesome. UserName.ToLower() == username will fail. Please change to UserName.ToLower() == username.ToLower() – moral Nov 20 '20 at 14:28
  • good point, there is no need to do lower at all, I have removed now. @user2021262 if the answer has helped you dont forget to upvote it. – Ricky Gummadi Nov 22 '20 at 21:02
3

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();
    }
}
Métoule
  • 13,062
  • 2
  • 56
  • 84
3

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.

  • This could be the better option, but please provide more details, so I can decide if it's doable. Thanks. – Seb Nilsson Mar 20 '20 at 13:59
  • 1
    I implemented something similar as Owin middleware for ASP.NET Web API. Owin/Katana middleware is basically precursor to ASP.NET Core middleware so maybe someone would find it useful: https://stackoverflow.com/a/61602929/350384 – Mariusz Pawelski May 04 '20 at 22:56
  • Works. The need to modify an existing middleware to only respond to particular path segments is somewhat inelegant. – primo Apr 08 '23 at 11:20
0

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>();
gurkan
  • 884
  • 4
  • 16
  • 25
Darshani Jayasekara
  • 561
  • 1
  • 4
  • 14