CoreWCF Basic Authentication - .NET 6

1.4k views Asked by At

I am writing a CoreWCF PoC and I need to use HTTPS, BasicHttpBinding and Basic Authentication.

It all worked fine until the point when I tried to activate Basic Authentication. So the code below with the Binding that sets ClientCredentialType to HttpClientCredentialType.Basic:

var basicHttpBinding = new BasicHttpBinding(BasicHttpSecurityMode.Transport);
basicHttpBinding.Security.Transport.ClientCredentialType = HttpClientCredentialType.Basic;

var app = builder.Build();
app.UseServiceModel(builder =>
{
     // Add service with a BasicHttpBinding at a specific endpoint
     builder.AddService<DownloadService>((serviceOptions) => {
         serviceOptions.DebugBehavior.IncludeExceptionDetailInFaults = true;
     }).AddServiceEndpoint<DownloadService, IDownloadService>(basicHttpBinding, "/DownloadService/basichttp");
});

throws an Exception on starting up: System.InvalidOperationException: 'Unable to resolve service for type 'Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider' while attempting to activate 'Microsoft.AspNetCore.Authentication.AuthenticationMiddleware'.'

Any idea how can I set up BasicAuthentication in CoreWCF to subsequently read who the logged in user is.

2

There are 2 answers

0
Jason Pan On BEST ANSWER

I have reproduced the issue you mentioned. I solve it by downgrading the CoreWCF package version to 1.0.2 or 1.0.1. Other versions (> 1.0.2) have the issue.

enter image description here


My test steps

enter image description here


Tips:

Pay attention to the order when downgrading these two packages, I forgot the specific order, you can try, you can definitely complete the downgrade.

0
Tore Aurstad On

Downgrading to a very early version of CoreWCF is not a very good solution in the long run as you will miss updates in CoreWCF.

I have written an article how to do Basic Auth with Core WCF here: https://toreaurstad.blogspot.com/2023/11/implementing-basic-auth-in-core-wcf.html

Note that this relies also on ASP.NET Core pipeline, both CoreWCF and ASP.NET Core pipeline must be set up for Basic Auth.

The client repo is here: https://github.com/toreaurstadboss/CoreWCFWebClient1

The serverside repo is here: https://github.com/toreaurstadboss/CoreWCFService1

On the clientside we have this extension method to set up basic auth:

Extension method WithBasicAuth:

BasicHttpBindingClientFactory.cs

using System.ServiceModel;
using System.ServiceModel.Channels;
namespace CoreWCFWebClient1.Extensions
{
    public static class BasicHttpBindingClientFactory
    {

        /// <summary>
        /// Creates a basic auth client with credentials set in header Authorization formatted as 'Basic [base64encoded username:password]'
        /// Makes it easier to perform basic auth in Asp.NET Core for WCF
        /// </summary>
        /// <param name="username"></param>
        /// <param name="password"></param>
        /// <returns></returns>
        public static TServiceImplementation WithBasicAuth<TServiceContract, TServiceImplementation>(this TServiceImplementation client, string username, string password)
              where TServiceContract : class
                where TServiceImplementation : ClientBase<TServiceContract>, new()
        {
            string clientUrl = client.Endpoint.Address.Uri.ToString();

            var binding = new BasicHttpsBinding();
            binding.Security.Mode = BasicHttpsSecurityMode.Transport;
            binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.Basic;

            string basicHeaderValue = "Basic " + Base64Encode($"{username}:{password}");
            var eab = new EndpointAddressBuilder(new EndpointAddress(clientUrl));
            eab.Headers.Add(AddressHeader.CreateAddressHeader("Authorization",  // Header Name
                string.Empty,           // Namespace
                basicHeaderValue));  // Header Value
            var endpointAddress = eab.ToEndpointAddress();

            var clientWithConfiguredBasicAuth = (TServiceImplementation) Activator.CreateInstance(typeof(TServiceImplementation), binding, endpointAddress)!;
            clientWithConfiguredBasicAuth.ClientCredentials.UserName.UserName = username;
            clientWithConfiguredBasicAuth.ClientCredentials.UserName.Password = username;

            return clientWithConfiguredBasicAuth;
        }

        private static string Base64Encode(string plainText)
        {
            var plainTextBytes = System.Text.Encoding.UTF8.GetBytes(plainText);
            return Convert.ToBase64String(plainTextBytes);
        }

    }
}

Next up is how you use this extension method inside for example a asp.net core mvc razor view :

@{

    string username = "someuser";
    string password = "somepassw0rd";

    var client = new ServiceClient().WithBasicAuth<IService, ServiceClient>(username, password);

    var result = await client.GetDataAsync(42);

    <h5>@Html.Raw(result)</h5>
}

Note that this sets up a BasicHttpsBinding with configured Authentication header inside the soap envelope. In addition, we set up also the ClientCredentials on the BasicHttpsBinding as CoreWCF demands this. However, the credentials will be read out from the soap envelope Authentication header. The format is : 'Basic [base64creds]' where [base64cred] is a base-64 encoded string of username:password credentials without the square brackets.

I have tested this with CoreWCF 1.5.1 on the serverside.

Csproj of the wcf serverside looks like this:

CoreWCFService1.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>true</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <Using Include="CoreWCF" />
    <Using Include="CoreWCF.Configuration" />
    <Using Include="CoreWCF.Channels" />
    <Using Include="CoreWCF.Description" />
    <Using Include="System.Runtime.Serialization " />
    <Using Include="CoreWCFService1" />
    <Using Include="Microsoft.Extensions.DependencyInjection.Extensions" />
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="CoreWCF.Primitives" Version="1.5.1" />
    <PackageReference Include="CoreWCF.Http" Version="1.5.1" />
  </ItemGroup>
</Project>

Some relevant lines from Program.cs to set up the basic authentication :

Program.cs

 builder.Services.AddSingleton<IUserRepository, UserRepository>();

builder.Services.AddAuthentication("Basic").
            AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>
            ("Basic", null);
app.Use(async (context, next) =>
{
    // Only check for basic auth when path is for the TransportWithMessageCredential endpoint only
    if (context.Request.Path.StartsWithSegments("/Service.svc"))
    {
        // Check if currently authenticated
        var authResult = await context.AuthenticateAsync("Basic");
        if (authResult.None)
        {
            // If the client hasn't authenticated, send a challenge to the client and complete request
            await context.ChallengeAsync("Basic");
            return;
        }
    }
    // Call the next delegate/middleware in the pipeline.
    // Either the request was authenticated of it's for a path which doesn't require basic auth
    await next(context);
});

app.UseServiceModel(serviceBuilder =>
{
    var basicHttpBinding = new BasicHttpBinding();
    basicHttpBinding.Security.Mode = BasicHttpSecurityMode.Transport;
    basicHttpBinding.Security.Transport.ClientCredentialType = HttpClientCredentialType.Basic;
    serviceBuilder.AddService<Service>(options =>
    {
        options.DebugBehavior.IncludeExceptionDetailInFaults = true;
    });
    serviceBuilder.AddServiceEndpoint<Service, IService>(basicHttpBinding, "/Service.svc");

    var serviceMetadataBehavior = app.Services.GetRequiredService<ServiceMetadataBehavior>();
    serviceMetadataBehavior.HttpsGetEnabled = true;
});

Checkout the mentioned Github repos above to see the entire code.

Next up , the basic authentication handler :

BasicAuthenticationHandler

using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Security.Principal;
using System.Text;
using System.Text.Encodings.Web;

public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{

    private readonly IUserRepository _userRepository;
    public BasicAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock, IUserRepository userRepository) :
       base(options, logger, encoder, clock)
    {
        _userRepository = userRepository;
    }

    protected async override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        string? authTicketFromSoapEnvelope = await Request!.GetAuthenticationHeaderFromSoapEnvelope();

        if (authTicketFromSoapEnvelope != null && authTicketFromSoapEnvelope.StartsWith("basic", StringComparison.OrdinalIgnoreCase))
        {
            var token = authTicketFromSoapEnvelope.Substring("Basic ".Length).Trim();
            var credentialsAsEncodedString = Encoding.UTF8.GetString(Convert.FromBase64String(token));
            var credentials = credentialsAsEncodedString.Split(':');
            if (await _userRepository.Authenticate(credentials[0], credentials[1]))
            {
                var identity = new GenericIdentity(credentials[0]);
                var claimsPrincipal = new ClaimsPrincipal(identity);
                var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
                return await Task.FromResult(AuthenticateResult.Success(ticket));
            }
        }

        return await Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
    }

    protected override Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        Response.StatusCode = 401;
        Response.Headers.Add("WWW-Authenticate", "Basic realm=\"thoushaltnotpass.com\"");
        Context.Response.WriteAsync("You are not logged in via Basic auth").Wait();
        return Task.CompletedTask;
    }

}

The user repository looks like this:

public interface IUserRepository
    {

        public Task<bool> Authenticate(string username, string password);
    }

    public class UserRepository : IUserRepository
    {
        public Task<bool> Authenticate(string username, string password)
        {
            //TODO: some dummie auth mechanism used here, make something more realistic such as DB user repo lookup or similar
            if (username == "someuser" && password == "somepassw0rd")
            {
                return Task.FromResult(true);
            }
            return Task.FromResult(false);
        }
    }

The service looks like this, note the use of [Authorize] attribute:

public class Service : IService
 {

     [Authorize]
     public string GetData(int value)
     {
         return $"You entered: {value}. <br />The client logged in with transport security with BasicAuth with https (BasicHttpsBinding).<br /><br />The username is set inside ServiceSecurityContext.Current.PrimaryIdentity.Name: {ServiceSecurityContext.Current.PrimaryIdentity.Name}. <br /> This username is also stored inside Thread.CurrentPrincipal.Identity.Name: {Thread.CurrentPrincipal?.Identity?.Name}";
     }

     public CompositeType GetDataUsingDataContract(CompositeType composite)
     {
         if (composite == null)
         {
             throw new ArgumentNullException("composite");
         }
         if (composite.BoolValue)
         {
             composite.StringValue += "Suffix";
         }
         return composite;
     }
 }

Here is the helper method in HttpRequestExtensions to read the soap authorization header , note the use of BodyReader and AdvanceTo, it must be the ones used for reading and rewinding the Request after reading it.

HttpRequestExtensions.cs

   using System.IO.Pipelines;
    using System.Text;
    using System.Xml.Linq;
    
    public static class HttpRequestExtensions
    {
    
        public static async Task<string?> GetAuthenticationHeaderFromSoapEnvelope(this HttpRequest request)
        {
            ReadResult requestBodyInBytes = await request.BodyReader.ReadAsync();
            string body = Encoding.UTF8.GetString(requestBodyInBytes.Buffer.FirstSpan);
            request.BodyReader.AdvanceTo(requestBodyInBytes.Buffer.Start, requestBodyInBytes.Buffer.End);
    
            string authTicketFromHeader = null;
    
            if (body?.Contains(@"http://schemas.xmlsoap.org/soap/envelope/") == true)
            {
                XNamespace ns = "http://schemas.xmlsoap.org/soap/envelope/";
                var soapEnvelope = XDocument.Parse(body);
                var headers = soapEnvelope.Descendants(ns + "Header").ToList();
    
                foreach (var header in headers)
                {
                    var authorizationElement = header.Element("Authorization");
                    if (!string.IsNullOrWhiteSpace(authorizationElement?.Value))
                    {
                        authTicketFromHeader = authorizationElement.Value;
                        break;
                    }
                }
            }
    
            return authTicketFromHeader;
        }
    
    } 

This works, but Authentication.Fail sadly gives 500 internal server error instead of 401, I have not figured out why that happens. But this screen shot shows the logged in user :

[![Displaying logged in user via Basic Auth Transport level security in Core WCF][1]][1] [1]: https://i.stack.imgur.com/Z4SJx.png

The code shown here should be refined of course and is somewhat of a hack to just make it work. Sadly, I could not find any good tutorials on this in CoreWCF. It perhaps shows that CoreWCF is somewhat early still for some scenarios, although CoreWCF has other authentication mechanism and better support for those. Basic Auth is not considered a very safe authentication mechanism and should be used always inside HTTPS. Also, it is not two factor and does not rely on proper encryption such as federated authentication.