Reputation: 2930
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.
Upvotes: 4
Views: 3412
Reputation: 3826
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.sstatic.net/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.
Upvotes: 1
Reputation: 22067
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.
My test steps
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.
Upvotes: 3