dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.43k stars 10.01k forks source link

Can't do Windows Authentication in Docker container in ASP.NET Core 2.1+ #13861

Closed davidmatson closed 5 years ago

davidmatson commented 5 years ago

Describe the bug

ASP.NET Core 2.1+ doesn't have a way to do Windows Authentication inside a Docker container, starting with version 2.1. In 2.0.x, using OWIN as a workaround (with HttpListener) worked.

To Reproduce

Use OWIN with HttpListener, and enable Windows Authentication using a gMSA in a Docker container.

Expected behavior

Http requests succeed

Actual behavior

Http requests fail with NotSupportedException

Additional context

Earlier, https://github.com/aspnet/HttpSysServer/issues/333 was closed as won't fix, so I'm unable to use HttpSysServer for this scenario and had to use a custom IServer implementation using OWIN's FeatureCollection as a workaround. But #3022 was also closed as won't fix for 2.1+, so that solution no longer works.

If I need to do Windows Authentication in a Docker container, what approach is recommended? The only documentation I'm seeing for implementing IServer shows using OWIN's FeatureCollection , and any other implementation seems non-trivial (and I couldn't find documentation/an example of how to do so).

blowdart commented 5 years ago

Do you actually need Windows Authentication or just seamless login?

3.0 will come with it's own kerberos authentication (although it'll be limited to username only, no groups or roles).

There's always ADFS, with the bonus there that you can move to a cloud hosted container, or indeed a linux container without changing anything.

davidmatson commented 5 years ago

Hey, Barry! For this scenario, I just need HTTP Negotiate or Kerberos auth (seamless login) that provides group membership for seamless on-prem authorization.

blowdart commented 5 years ago

@Tratcher

Tratcher commented 5 years ago

Note #3022 was able to work around the issue so it's not completely blocked.

These are windows containers, right? Have you tried the new 3.0 auth features?

davidmatson commented 5 years ago

Looking through that issue, it sounded like the workaround was to use a different feature collection part for OnComplete, though I'm not exactly sure what that would look like. Do you have any details? (I didn't see how to from a quick look through the thread).

These are Windows containers. We're still on 2.x and I'm rather not move to 3.0 yet if possible. Either way, from what Barry said above it sounded like its auth features wouldn't give us group membership information, which we need in this case. Let me know if that's incorrect though.

Tratcher commented 5 years ago

The first entry in #3022 says "I copied the OwinFeatureCollection code into my own project and commented out the throw... and model binding now works"

The groups are available on Windows, just not Linux.

davidmatson commented 5 years ago

I thought that wasn't really an option:

Removing the NotSupportException and silently ignoring OnCompleted isn't really an option, it would cause memory leaks. 

Good to hear about groups in 3.0. Will they work when running under a gMSA? Any suggestions for what do while on 2.x?

Thanks.

Tratcher commented 5 years ago

OwinFeatureCollection couldn't implement OnCompleted at a middleware layer, it would fire too early. At a IServer layer though you control the lifetime of the request and could fire OnCompleted once everything is really completed.

Good to hear about groups in 3.0. Will they work when running under a gMSA?

No idea. I'm curious to hear what you find.

Any suggestions for what do while on 2.x?

No, 2.x didn't have all the infrastructure to build this. You could backport most of our Negotiate package on Windows but we added several server features in 3.0 to help track and clean up the connection auth state. Not sure if you'd be able to work around that.

davidmatson commented 5 years ago

Thanks, Chris. I'll probably investigate a custom FeatureCollection (not using OWIN) as a 2.x workaround for now - any suggestions on how to do a full custom IServer, or is there any documentation?

davidmatson commented 5 years ago

I tried doing a full implementation of FeatureCollection for HttpListener, but that was hours worth of tricky work including request thread pool handling. I ended up just wrapping the IHttpResponseFeature from OWIN and customizing the handling of OnComplete (and then calling these callbacks later). I'd rather find a way to get rid of OWIN, but implementing IServer seems too much work to be worth a custom implementation.

I'm guessing I should be calling the complete callbacks after awaiting application.ProcessRequestAsync and before calling application.DisposeContext - is that correct?

Tratcher commented 5 years ago

Sounds right.

davidmatson commented 5 years ago

In case someone else needs the 2.x workaround, here's what I used:

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;

namespace Servers
{
    class CompletableHttpResponseFeature : IHttpResponseFeature
    {
        readonly IHttpResponseFeature inner;
        readonly List<CallbackRegistration> completedRegistrations = new List<CallbackRegistration>();

        public CompletableHttpResponseFeature(IHttpResponseFeature inner)
        {
            Debug.Assert(inner != null);

            this.inner = inner;
        }

        public int StatusCode { get => inner.StatusCode; set => inner.StatusCode = value; }

        public string ReasonPhrase { get => inner.ReasonPhrase; set => inner.ReasonPhrase = value; }

        public IHeaderDictionary Headers { get => inner.Headers; set => inner.Headers = value; }

        public Stream Body { get => inner.Body; set => inner.Body = value; }

        public bool HasStarted => inner.HasStarted;

        public void OnCompleted(Func<object, Task> callback, object state)
        {
            if (callback == null)
            {
                throw new ArgumentNullException(nameof(callback));
            }

            completedRegistrations.Add(new CallbackRegistration(callback, state));
        }

        public void OnStarting(Func<object, Task> callback, object state)
        {
            inner.OnStarting(callback, state);
        }

        public async Task CompleteAsync()
        {
            foreach (CallbackRegistration registration in completedRegistrations)
            {
                Func<object, Task> callback = registration.Callback;
                object state = registration.State;

                await callback.Invoke(state);
            }
        }

        struct CallbackRegistration
        {
            readonly Func<object, Task> callback;
            readonly object state;

            public CallbackRegistration(Func<object, Task> callback, object state)
            {
                Debug.Assert(callback != null);

                this.callback = callback;
                this.state = state;
            }

            public Func<object, Task> Callback => callback;

            public object State => state;
        }
    }
}

HttpListenerServer snippet:

            server = OwinServerFactory.Create(async (environment) =>
            {
                IFeatureCollection readOnlyFeatureCollection = new OwinFeatureCollection(environment);
                IFeatureCollection mutableFeatureCollection = new FeatureCollection(readOnlyFeatureCollection);
                IHttpResponseFeature owinHttpResponseFeature = readOnlyFeatureCollection.Get<IHttpResponseFeature>();
                CompletableHttpResponseFeature completableHttpResponseFeature =
                    new CompletableHttpResponseFeature(owinHttpResponseFeature);
                // See: https://github.com/aspnet/AspNetCore/issues/13861
                mutableFeatureCollection.Set<IHttpResponseFeature>(completableHttpResponseFeature);

                TContext context = application.CreateContext(mutableFeatureCollection);

                try
                {
                    await application.ProcessRequestAsync(context);
                }
                catch (Exception exception)
                {
                    await completableHttpResponseFeature.CompleteAsync();
                    application.DisposeContext(context, exception);
                    throw;
                }

                await completableHttpResponseFeature.CompleteAsync();
                application.DisposeContext(context, null);
            }, properties);
davidmatson commented 5 years ago

I tested the 3.x auth features (Negotiate package), and it looks like it's working fine with a gMSA in a docker container!

In case we need to test again in the future, here's the 3.x auth test code I used: Test.csproj:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>netcoreapp3.0</TargetFramework>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.Authentication.Negotiate" Version="3.0.0-preview9.19424.4" />
    </ItemGroup>
</Project>

Program.cs:

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Hosting;

static class Program
{
    static void Main()
    {
        IHostBuilder hostBuilder = new HostBuilder()
            .ConfigureLogging(logging => logging.AddConsole())
            .ConfigureWebHostDefaults(webHost => webHost.UseStartup<Startup>());

        using (IHost host = hostBuilder.Build())
        {
            host.Run();
        }
    }
}

Startup.cs:

using Microsoft.AspNetCore.Authentication.Negotiate;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;

class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
            .AddNegotiate();
        services.AddMvc();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        app.UseDeveloperExceptionPage();
        app.UseRouting();
        app.UseAuthentication();
        app.UseAuthorization();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=Index}/{id?}");
        });
    }
}

HomeController.cs:

using System.Security.Claims;
using System.Security.Principal;
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;

[Authorize]
public class HomeController : Controller
{
    public IActionResult Index()
    {
        StringBuilder builder = new StringBuilder();
        builder.AppendLine($"User.Identity.IsAuthenticated: {User.Identity.IsAuthenticated}");
        builder.AppendLine($"User.Identity.AuthenticationType: {User.Identity.AuthenticationType}");
        builder.AppendLine($"User.Identity.Name: {User.Identity.Name}");
        builder.AppendLine("User.Claims");
        builder.AppendLine();

        foreach (Claim claim in User.Claims)
        {
            builder.AppendLine($"\tType: {claim.Type}");
            builder.AppendLine($"\tValue: {claim.Value}");
            builder.AppendLine($"\tIssuer: {claim.Issuer}");

            if (claim.Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid")
            {
                SecurityIdentifier securityIdentifier = new SecurityIdentifier(claim.Value);
                NTAccount ntAccount = (NTAccount)securityIdentifier.Translate(typeof(NTAccount));
                builder.AppendLine($"\tNTAccount.Value: {ntAccount.Value}");
            }

            builder.AppendLine();
        }

        return Content($"<pre>{builder.ToString()}</pre>", new MediaTypeHeaderValue("text/html"));
    }
}