Configuring asp.net core identity oauth2 authentication with user provided oauth2 credentials - asp.net-core

I build an application that extends the gitlab user experience and admins of an organization (Organizations are the tenants in the system) can configure their gitlab installation (register an OAuth2 application in their gitlab instance) and normal users in an organization can just authenticate themselves with their gitlab account via OAuth2.
My problem at the moment is, the credentials (oauth2 client id and client secret, as well as the base url) are provided by the organization admin and are stored in the database. I want to give every organization its own subdomain and the Sign In with Gitlab button should redirect the user to their gitlab instance and follow the usual oauth2 flow for authentication, but I can't figure out how to configure the asp.net core identity framework to decide on the fly (based on the subdomain) which credentials to use for the oauth2 flow. All tutorials and microsoft provided documentations assume that you only have one "hard coded" oauth2 provided (usually configured in the ConfigureServices method of the Startup class).
My current implementation follows the microsoft provided documentation and looks like this:
public void ConfigureServices(IServiceCollection services)
{
// ...
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = "Gitlab";
}).AddCookie()
.AddOAuth("Gitlab", options =>
{
options.ClientId = Configuration["Gitlab:ClientId"];
options.ClientSecret = Configuration["Gitlab:ClientSecret"];
options.CallbackPath = new Microsoft.AspNetCore.Http.PathString("/signin-gitlab");
options.AuthorizationEndpoint = Configuration["Gitlab:BaseUrl"] + "/oauth/authorize";
options.TokenEndpoint = Configuration["Gitlab:BaseUrl"] + "/oauth/token";
options.UserInformationEndpoint = Configuration["Gitlab:BaseUrl"] + "/api/v4/user";
options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
options.ClaimActions.MapJsonKey("gitlab:avatar_url", "avatar_url");
options.ClaimActions.MapJsonKey("gitlab:profile_url", "web_url");
options.SaveTokens = true;
options.Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);
var response = await context.Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
var user = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
context.RunClaimActions(user);
}
};
});
}
How can I implement such a system?

The OAuth handler uses the options pattern for configuration, which means you can utilize it to set properties such as ClientId, ClientSecret, etc, dynamically, on per-request basis, based on request properties.
You need to do the following (please bear with any compile problems, I used it with different options so writing this mostly from my head):
Modify the ConfigureServices body as follows:
public void ConfigureServices(IServiceCollection services)
{
// ...
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = "Gitlab";
}).AddCookie()
.AddOAuth("Gitlab", delegate { }); // Don't specify hard coded OAuth options. Instead, you will return them from an options provider.
services.AddTransient<TenantResolver>();
services.AddSingleton<OAuthOptionsCacheAccessor>();
services.AddTransient<IConfigureNamedOptions<OAuthOptions>, OAuthOptionsInitializer>();
services.AddTransient<IOptionsMonitor<OAuthOptions>, OAuthOptionsProvider>();
}
Implement your tenant resolution logic based on the incoming request and register it to DI. For example:
public class TenantResolver // don't forget to register this to DI in ConfigureServices
{
private readonly IHttpContextAccessor httpContextAccessor;
public TenantAuthorityResolver(IHttpContextAccessor httpContextAccessor)
{
this.httpContextAccessor = httpContextAccessor;
}
public string GetCurrentTenant()
{
// TODO: Read the current request from httpContextAccessor.HttpContext.Request
// and parse it to resolve the current tenant Id based on your own logic
}
}
Use the cache to store your instances of OAuthOptions and register it to DI as singleton. I used the ConcurrentDictionary like this:
public class OAuthOptionsCacheAccessor // register to DI as singleton
{
public ConcurrentDictionary<(string name, string tenant), Lazy<OAuthOptions>> Cache =>
new ConcurrentDictionary<(string, string), Lazy<OAuthOptions>>();
}
Implement the options initializer which will return the correct OAuthOptions instance based on the resolved tenant, and register this class to DI as a transient dependency.
public class OAuthOptionsInitializer : IConfigureNamedOptions<OAuthOptions> // register as transient
{
private readonly IDataProtectionProvider dataProtectionProvider;
private readonly TenantResolver tenantResolver;
public OAuthOptionsInitializer(
IDataProtectionProvider dataProtectionProvider,
TenantResolver tenantResolver)
{
this.dataProtectionProvider = dataProtectionProvider;
this.tenantResolver = tenantResolver;
}
public void Configure(string name, OAuthOptions options)
{
if (!string.Equals(name, OpenIdConnectDefaults.AuthenticationScheme, StringComparison.Ordinal))
{
return;
}
var tenant = tenantResolver.GetCurrentTenant();
// TODO: You will probably want to save your per-tenant OAuth options
// in the database or somewhere, so now is the time to obtain those.
// I also recommend using Nito.AsyncEx to be able to safely call async methods from here
var savedOptions = Nito.AsyncEx.AsyncContext.Run(async () => await GetSavedOptions(tenant));
options.ClientId = savedOptions.ClientId;
options.ClientSecret = savedOptions.ClientSecret;
options.CallbackPath = new Microsoft.AspNetCore.Http.PathString("/signin-gitlab");
options.AuthorizationEndpoint = savedOptions.BaseUrl + "/oauth/authorize";
options.TokenEndpoint = savedOptions.BaseUrl + "/oauth/token";
options.UserInformationEndpoint = savedOptions.BaseUrl + "/api/v4/user";
options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
options.ClaimActions.MapJsonKey("gitlab:avatar_url", "avatar_url");
options.ClaimActions.MapJsonKey("gitlab:profile_url", "web_url");
options.SaveTokens = true;
options.Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);
var response = await context.Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
var user = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
context.RunClaimActions(user);
}
};
}
public void Configure(OpenIdConnectOptions options)
=> Debug.Fail("This infrastructure method shouldn't be called.");
}
And finally, implement the OAuth options provider and register it to DI as transient:
public class OAuthOptionsProvider : IOptionsMonitor<OAuthOptions>
{
private readonly OAuthOptionsCacheAccessor cacheAccessor;
private readonly IOptionsFactory<OAuthOptions> optionsFactory;
private readonly TenantResolver tenantResolver;
public OAuthOptionsProvider(
IOptionsFactory<OAuthOptions> optionsFactory,
TenantResolver tenantResolver,
OAuthOptionsCacheAccessor cacheAccessor)
{
this.cacheAccessor = cacheAccessor;
this.optionsFactory = optionsFactory;
this.tenantAuthorityResolver = tenantAuthorityResolver;
}
public OAuthOptions CurrentValue => Get(Options.DefaultName);
public OAuthOptions Get(string name)
{
var tenant = tenantResolver.GetCurrentTenant();
Lazy<OAuthOptions> Create() => new Lazy<OAuthOptions>(() => optionsFactory.Create(name));
return cacheAccessor.Cache.GetOrAdd((name, tenant), _ => Create()).Value;
}
public IDisposable OnChange(Action<OAuthOptions, string> listener) => null;
}
And not to forget, I want to attribute the original answer for this idea: https://stackoverflow.com/a/52977687/828023

Related

Why is my custom `AuthentictionStateProvider` not null in AddSingleton but null in AddScoped

I had previously asked a question that was answered properly, but the problem is that when my custom AuthenticationStateProvider is registered as a scoped
services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
I get the following error:
System.InvalidOperationException: GetAuthenticationStateAsync was called before SetAuthenticationState
But, when it is registered as a singleton, it works correctly, However, the single instance creates for the lifetime of the application domain by AddSingelton, and so this is not good.(Why? Because of :))
What should I do to register my custom AuthenticationStateProvider as a scoped, but its value is not null?
Edit:
According to #MrC aka Shaun Curtis Comment:
It's my CustomAuthenticationStateProvider:
public class CustomAuthenticationStateProvider : RevalidatingServerAuthenticationStateProvider
{
private readonly IServiceScopeFactory _scopeFactory;
public CustomAuthenticationStateProvider(ILoggerFactory loggerFactory, IServiceScopeFactory scopeFactory)
: base(loggerFactory) =>
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
protected override TimeSpan RevalidationInterval { get; } = TimeSpan.FromMinutes(30);
protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken)
{
// Get the user from a new scope to ensure it fetches fresh data
var scope = _scopeFactory.CreateScope();
try
{
var userManager = scope.ServiceProvider.GetRequiredService<IUsersService>();
return await ValidateUserAsync(userManager, authenticationState?.User);
}
finally
{
if (scope is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
scope.Dispose();
}
}
}
private async Task<bool> ValidateUserAsync(IUsersService userManager, ClaimsPrincipal? principal)
{
if (principal is null)
{
return false;
}
var userIdString = principal.FindFirst(ClaimTypes.UserData)?.Value;
if (!int.TryParse(userIdString, out var userId))
{
return false;
}
var user = await userManager.FindUserAsync(userId);
return user is not null;
}
}
And it's a program configuration and service registration:
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
services.AddServerSideBlazor();
#region Authentication
//Authentication
services.AddDbContextFactory<ApplicationDbContext>(options =>
{
options.UseSqlServer(
Configuration.GetConnectionString("LocalDBConnection"),
serverDbContextOptionsBuilder =>
{
var minutes = (int)TimeSpan.FromMinutes(3).TotalSeconds;
serverDbContextOptionsBuilder.CommandTimeout(minutes);
serverDbContextOptionsBuilder.EnableRetryOnFailure();
})
.AddInterceptors(new CorrectCommandInterceptor()); ;
});
//add policy
services.AddAuthorization(options =>
{
options.AddPolicy(CustomRoles.Admin, policy => policy.RequireRole(CustomRoles.Admin));
options.AddPolicy(CustomRoles.User, policy => policy.RequireRole(CustomRoles.User));
});
// Needed for cookie auth.
services
.AddAuthentication(options =>
{
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.SlidingExpiration = false;
options.LoginPath = "/";
options.LogoutPath = "/login";
//options.AccessDeniedPath = new PathString("/Home/Forbidden/");
options.Cookie.Name = ".my.app1.cookie";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = context =>
{
var cookieValidatorService = context.HttpContext.RequestServices.GetRequiredService<ICookieValidatorService>();
return cookieValidatorService.ValidateAsync(context);
}
};
});
#endregion
//AutoMapper
services.AddAutoMapper(typeof(MappingProfile).Assembly);
//CustomAuthenticationStateProvider
services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
.
.
}
Don't worry about the AddSingelton in the Blazor apps. Scoped dependencies act the same as Singleton registered dependencies in Blazor apps (^).
Blazor WebAssembly apps don't currently have a concept of DI scopes. Scoped-registered services behave like Singleton services.
The Blazor Server hosting model supports the Scoped lifetime across HTTP requests (Just for the Razor Pages or MVC portion of the app) but not across SignalR connection/circuit messages among components that are loaded on the client.
That's why there's a scope.ServiceProvider.GetRequiredService here to ensure the retrived user is fetched from a new scope and has a fresh data.
Actually this solution is taken from the Microsoft's sample.
Your problem is probably here:
var scope = _scopeFactory.CreateScope();
/...
var userManager = scope.ServiceProvider.GetRequiredService<IUsersService>();
You create a new IOC container and request the instance of IUsersService from that container.
If IUsersService is Scoped, it creates a new instance.
IUsersService requires various other services which the new container must provide.
public UsersService(IUnitOfWork uow, ISecurityService securityService, ApplicationDbContext dbContext, IMapper mapper)
Here's the definition of those services in Startup:
services.AddScoped<IUnitOfWork, ApplicationDbContext>();
services.AddScoped<IUsersService, UsersService>();
services.AddScoped<IRolesService, RolesService>();
services.AddScoped<ISecurityService, SecurityService>();
services.AddScoped<ICookieValidatorService, CookieValidatorService>();
services.AddScoped<IDbInitializerService, DbInitializerService>();
IUnitOfWork and ISecurityService are both Scoped, so it creates new instances of these in the the new Container. You almost certainly don't want that: you want to use the ones from the Hub SPA session container.
You have a bit of a tangled web so without a full view of everything I can't be sure how to restructure things to make it work.
One thing you can try is to just get a standalone instance of IUsersService from the IOC container using ActivatorUtilities. This instance gets instantiated with all the Scoped services from the main container. Make sure you Dispose it if it implements IDisposable.
public class CustomAuthenticationStateProvider : RevalidatingServerAuthenticationStateProvider
{
private readonly IServiceProvider _serviceProvider;
public CustomAuthenticationStateProvider(ILoggerFactory loggerFactory, IServiceProvider serviceProvider)
: base(loggerFactory) =>
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(scopeFactory));
protected override TimeSpan RevalidationInterval { get; } = TimeSpan.FromMinutes(30);
protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken)
{
// Get an instance of IUsersService from the IOC Container Service to ensure it fetches fresh data
IUsersService userManager = null;
try
{
userManager = ActivatorUtilities.CreateInstance<IUsersService>(_serviceProvider);
return await ValidateUserAsync(userManager, authenticationState?.User);
}
finally
{
userManager?.Dispose();
}
}
private async Task<bool> ValidateUserAsync(IUsersService userManager, ClaimsPrincipal? principal)
{
if (principal is null)
{
return false;
}
var userIdString = principal.FindFirst(ClaimTypes.UserData)?.Value;
if (!int.TryParse(userIdString, out var userId))
{
return false;
}
var user = await userManager.FindUserAsync(userId);
return user is not null;
}
}
For reference this is my test code using the standard ServerAuthenticationStateProvider in a Blazor Server Windows Auth project.
public class MyAuthenticationProvider : ServerAuthenticationStateProvider
{
IServiceProvider _serviceProvider;
public MyAuthenticationProvider(IServiceProvider serviceProvider, MyService myService)
{
_serviceProvider = serviceProvider;
}
public override Task<AuthenticationState> GetAuthenticationStateAsync()
{
public override Task<AuthenticationState> GetAuthenticationStateAsync()
{
MyService? service = null;
try
{
service = ActivatorUtilities.CreateInstance<MyService>(_serviceProvider);
// Do something with service
}
finally
{
service?.Dispose();
}
return base.GetAuthenticationStateAsync();
}
}

Multiple authentication schemes in ASP.NET Core 5.0 WebAPI

I have a full set of (ASP.NET Core) web APIs developed in .NET 5.0 and implemented Cookies & OpenIdConnect authentication schemes.
After successful authentication (user id and password) with Azure AD, cookie is generated and stores user permissions etc.
Now, I would like to expose the same set of APIs to a third party consumer using API Key based authentication (via api-key in the request headers).
I have developed a custom authentication handler as below.
using Microsoft.AspNetCore.Authentication;
namespace Management.Deployment.Securities.Authentication
{
public class ApiKeyAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
}
}
namespace Management.Deployment.Securities.Authentication
{
public static class ApiKeyAuthenticationDefaults
{
public static readonly string AuthenticationScheme = "ApiKey";
public static readonly string DisplayName = "ApiKey Authentication Scheme";
}
}
ApiKeyAuthenticationHandler is defined as below, straight forward, if the request headers contain the valid api key then add permissions claim (assigned to the api key) and mark the authentication as success else fail.
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Management.Securities.Authorization;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
namespace Management.Deployment.Securities.Authentication
{
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationSchemeOptions>
{
private const string APIKEY_NAME = "x-api-key";
private const string APIKEY_VALUE = "sdflasuowerposaddfsadf1121234kjdsflkj";
public ApiKeyAuthenticationHandler(IOptionsMonitor<ApiKeyAuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
string extractedApiKey = Request.Headers[APIKEY_NAME];
if (!APIKEY_VALUE.Equals(extractedApiKey))
{
return Task.FromResult(AuthenticateResult.Fail("Unauthorized client."));
}
var claims = new[]
{
new Claim("Permissions", "23")
};
var claimsIdentity = new ClaimsIdentity(claims, nameof(ApiKeyAuthenticationHandler));
var authenticationTicket = new AuthenticationTicket(new ClaimsPrincipal(claimsIdentity), Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(authenticationTicket));
}
}
}
I have also defined ApiKeyAuthenticationExtensions as below.
using Microsoft.AspNetCore.Authentication;
using Management.Deployment.Securities.Authentication;
using System;
namespace Microsoft.Extensions.DependencyInjection
{
public static class ApiKeyAuthenticationExtensions
{
public static AuthenticationBuilder AddApiKey(this AuthenticationBuilder builder)
{
return builder.AddScheme<ApiKeyAuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationDefaults.AuthenticationScheme, ApiKeyAuthenticationDefaults.DisplayName, x => { });
}
public static AuthenticationBuilder AddApiKey(this AuthenticationBuilder builder, Action<ApiKeyAuthenticationSchemeOptions> configureOptions)
{
return builder.AddScheme<ApiKeyAuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationDefaults.AuthenticationScheme, ApiKeyAuthenticationDefaults.DisplayName, configureOptions);
}
}
}
Skimmed version of ConfigureServices() in Startup.cs is here. Please note I have used ForwardDefaultSelector.
public void ConfigureServices(IServiceCollection services)
{
IAuthCookieValidate cookieEvent = new AuthCookieValidate();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.Cookie.Name = ".Mgnt.AspNetCore.Cookies";
options.ExpireTimeSpan = TimeSpan.FromDays(1);
options.Events = new CookieAuthenticationEvents
{
OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = 403;
return Task.FromResult(0);
},
OnRedirectToLogin = context =>
{
context.Response.StatusCode = 401;
return Task.FromResult(0);
},
OnValidatePrincipal = cookieEvent.ValidateAsync
};
options.ForwardDefaultSelector = context =>
{
return context.Request.Headers.ContainsKey(ApiConstants.APIKEY_NAME) ? ApiKeyAuthenticationDefaults.AuthenticationScheme : CookieAuthenticationDefaults.AuthenticationScheme;
};
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => Configuration.Bind(OpenIdConnectDefaults.AuthenticationScheme, options))
.AddApiKey();
services.Configure<OpenIdConnectOptions>(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
});
services.AddSingleton<IAuthorizationPolicyProvider, AuthorizationPolicyProvider>();
services.AddSingleton<IAuthorizationHandler, PermissionHandler>();
}
The Configure method is as below.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseHsts();
app.Use((context, next) =>
{
context.Request.Scheme = "https";
return next();
});
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
When I send the correct apikey in the request headers, the custom handler is returning success as authentication result and the request is processed further.
But if an incorrect api key is passed, it is not returning the authentication failure message - "Unauthorized client.". Rather, the request is processed further and sending the attached response.
What changes to be made to resolve this issue so the api returns the authentication failure message - "Unauthorized client." and stops further processing of the request?
if you plan to use apikeys, then you are on your own and there is (as far as I know) no built in direct support for API-keys. There is however built in support for JWT based access tokens and I would recommend that you use that as well for external third parties who wants to access your api. Perhaps using client credentials flow.
For some help, see http://codingsonata.com/secure-asp-net-core-web-api-using-api-key-authentication/
I also think you should configure and let the authorization handler be responsible for deciding who can access the services.
see Policy-based authorization in ASP.NET Core

Razor Pages .NET Core 2.1 Integration Testing post authentication

I am looking for some guidance...
I'm currently looking at trying to write some integration tests for a Razor Pages app in .net core 2.1, the pages I'm wanting to test are post authentication but I'm not sure about the best way of approaching it. The docs seem to suggest creating a CustomWebApplicationFactory, but apart from that I've got a little bit lost as how I can fake/mock an authenticated user/request, using basic cookie based authentication.
I've seen that there is an open GitHub issue against the Microsoft docs (here is the actual GitHub issue), there was a mentioned solution using IdentityServer4 but I’m just looking how to do this just using cookie based authentication.
Has anyone got any guidance they may be able to suggest?
Thanks in advance
My Code so far is:
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
{
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseMySql(connectionString);
options.EnableSensitiveDataLogging();
});
services.AddLogging(builder =>
{
builder.AddSeq();
});
services.ConfigureAuthentication();
services.ConfigureRouting();
}
}
ConfigureAuthentication.cs
namespace MyCarparks.Configuration.Startup
{
public static partial class ConfigurationExtensions
{
public static IServiceCollection ConfigureAuthentication(this IServiceCollection services)
{
services.AddIdentity<MyCarparksUser, IdentityRole>(cfg =>
{
//cfg.SignIn.RequireConfirmedEmail = true;
})
.AddDefaultUI()
.AddDefaultTokenProviders()
.AddEntityFrameworkStores<ApplicationDbContext>();
services.ConfigureApplicationCookie(options =>
{
options.LoginPath = $"/Identity/Account/Login";
options.LogoutPath = $"/Identity/Account/Logout";
options.AccessDeniedPath = $"/Identity/Account/AccessDenied";
});
services.AddMvc()
.SetCompatibilityVersion(CompatibilityVersion.Version_2_1)
.AddRazorPagesOptions(options =>
{
options.AllowAreas = true;
options.Conventions.AuthorizeAreaFolder("Identity", "/Account/Manage");
options.Conventions.AuthorizeAreaPage("Identity", "/Account/Logout");
options.Conventions.AuthorizeFolder("/Sites");
});
return services;
}
}
}
Integration Tests
PageTests.cs
namespace MyCarparks.Web.IntegrationTests
{
public class PageTests : IClassFixture<CustomWebApplicationFactory<Startup>>
{
private readonly CustomWebApplicationFactory<Startup> factory;
public PageTests(CustomWebApplicationFactory<Startup> webApplicationFactory)
{
factory = webApplicationFactory;
}
[Fact]
public async Task SitesReturnsSuccessAndCorrectContentTypeAndSummary()
{
var siteId = Guid.NewGuid();
var site = new Site { Id = siteId, Address = "Test site address" };
var mockSite = new Mock<ISitesRepository>();
mockSite.Setup(s => s.GetSiteById(It.IsAny<Guid>())).ReturnsAsync(site);
// Arrange
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("http://localhost:44318/sites/sitedetails?siteId=" + siteId);
// Assert
response.EnsureSuccessStatusCode();
response.Content.Headers.ContentType.ToString()
.Should().Be("text/html; charset=utf-8");
var responseString = await response.Content.ReadAsStringAsync();
responseString.Should().Contain("Site Details - MyCarparks");
}
public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<Startup>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseStartup<Startup>();
}
}
}
For implement your requirement, you could try code below which creates the client with the authentication cookies.
public class CustomWebApplicationFactory<TEntryPoint> : WebApplicationFactory<TEntryPoint> where TEntryPoint : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
});
base.ConfigureWebHost(builder);
}
public new HttpClient CreateClient()
{
var cookieContainer = new CookieContainer();
var uri = new Uri("https://localhost:44344/Identity/Account/Login");
var httpClientHandler = new HttpClientHandler
{
CookieContainer = cookieContainer
};
HttpClient httpClient = new HttpClient(httpClientHandler);
var verificationToken = GetVerificationToken(httpClient, "https://localhost:44344/Identity/Account/Login");
var contentToSend = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("Email", "test#outlook.com"),
new KeyValuePair<string, string>("Password", "1qaz#WSX"),
new KeyValuePair<string, string>("__RequestVerificationToken", verificationToken),
});
var response = httpClient.PostAsync("https://localhost:44344/Identity/Account/Login", contentToSend).Result;
var cookies = cookieContainer.GetCookies(new Uri("https://localhost:44344/Identity/Account/Login"));
cookieContainer.Add(cookies);
var client = new HttpClient(httpClientHandler);
return client;
}
private string GetVerificationToken(HttpClient client, string url)
{
HttpResponseMessage response = client.GetAsync(url).Result;
var verificationToken =response.Content.ReadAsStringAsync().Result;
if (verificationToken != null && verificationToken.Length > 0)
{
verificationToken = verificationToken.Substring(verificationToken.IndexOf("__RequestVerificationToken"));
verificationToken = verificationToken.Substring(verificationToken.IndexOf("value=\"") + 7);
verificationToken = verificationToken.Substring(0, verificationToken.IndexOf("\""));
}
return verificationToken;
}
}
Following Chris Pratt suggestion, but for Razor Pages .NET Core 3.1 I use a previous request to authenticate against login endpoint (which is also another razor page), and grab the cookie from the response. Then I add the same cookie as part of the http request, and voila, it's an authenticated request.
This is a piece of code that uses an HttpClient and AngleSharp, as the official microsoft documentation, to test a razor page. So I reuse it to grab the cookie from the response.
private async Task<string> GetAuthenticationCookie()
{
var formName = nameof(LoginModel.LoginForm); //this is the bounded model for the login page
var dto =
new Dictionary<string, string>
{
[$"{formName}.Username"] = "foo",
[$"{formName}.Password"] = "bar",
};
var page = HttpClient.GetAsync("/login").GetAwaiter().GetResult();
var content = HtmlHelpers.GetDocumentAsync(page).GetAwaiter().GetResult();
//this is the AndleSharp
var authResult =
await HttpClient
.SendAsync(
(IHtmlFormElement)content.QuerySelector("form[id='login-form']"),
(IHtmlButtonElement)content.QuerySelector("form[id='login-form']")
.QuerySelector("button"),
dto);
_ = authResult.Headers.TryGetValues("Set-Cookie", out var values);
return values.First();
}
Then that value can be reused and passed with the new http request.
//in my test, the cookie is a given (from the given-when-then approach) pre-requirement
protected void Given()
{
var cookie = GetAuthenticationCookie().GetAwaiter().GetResult();
//The http client is a property that comes from the TestServer, when creating a client http for tests as usual. Only this time I set the auth cookie to it
HttpClient.DefaultRequestHeaders.Add("Set-Cookie", cookie);
var page = await HttpClient.GetAsync($"/admin/protectedPage");
//this will be a 200 OK because it's an authenticated request with whatever claims and identity the /login page applied
}

Hangfire aspnetcore2 default authentication challenge not working

Using hangfire version: 1.6.17
I have successfully setup hangifire on aspnetcore 2.0
I added authorization by using:
app.UseHangfireDashboard("/jobs", new DashboardOptions
{
Authorization = new[] { new HangfireAuthorizationFilter() }
});
and
public class HangfireAuthorizationFilter :IDashboardAuthorizationFilter
{
private const string PERMISSION = "read:jobs";
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
// allow only users with correct permission
if (httpContext.User.Identity.IsAuthenticated)
{
var permissions = httpContext.User.Claims.FirstOrDefault(x => x.Type.Equals(CustomClaims.Permissions))?.Value?.Split(' ');
return permissions?.Contains(PERMISSION) ?? false;
}
return false;
}
}
The only problem i cannot resolve is that a blank screen with 401 is returned to the user instead of the default challenge /account/login.
If you access my controllers with the [Authorize] attribute, they are automatically redirected to /account/login, so the loginpath is working.
Even if i specify it specifically, the user is not redirected while accessing Hangfire unauthorised:
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = "/Account/Login/";
})
Somebody an idea or should i mark it as a bug at Hangfire github.
First you should add HangFireAuthorizationFilter
public sealed class HangFireAuthorizationFilter : IDashboardAuthorizationFilter
{
private readonly IAuthorizationService _authorizationService;
private readonly IHttpContextAccessor _httpContextAccessor;
public HangFireAuthorizationFilter(IAuthorizationService authorizationService,
IHttpContextAccessor httpContextAccessor)
{
_authorizationService = authorizationService;
_httpContextAccessor = httpContextAccessor;
}
public bool Authorize([NotNull] DashboardContext context)
{
var httpContext = context.GetHttpContext();
return httpContext.User.Identity.IsAuthenticated;
}
}
Then use the below in the startup:
var hangFireAuth = new DashboardOptions
{
Authorization = new[]
{
new HangFireAuthorizationFilter(app.ApplicationServices.GetService<IAuthorizationService>(),
app.ApplicationServices.GetService<IHttpContextAccessor>())
},
AppPath = "/login"
};
app.UseHangfireDashboard("/hangfire", options: hangFireAuth);
You can refer to the following https://medium.com/ricos-note/hangfire-dashboard-of-authorization-b41d7135b044 for more details

SignalR core not working with cookie Authentication

I cant seem to get SignalR core to work with cookie authentication. I have set up a test project that can successfully authenticate and make subsequent calls to a controller that requires authorization. So the regular authentication seems to be working.
But afterwards, when I try and connect to a hub and then trigger methods on the hub marked with Authorize the call will fail with this message: Authorization failed for user: (null)
I inserted a dummy middleware to inspect the requests as they come in. When calling connection.StartAsync() from my client (xamarin mobile app), I receive an OPTIONS request with context.User.Identity.IsAuthenticated being equal to true. Directly after that OnConnectedAsync on my hub gets called. At this point _contextAccessor.HttpContext.User.Identity.IsAuthenticated is false. What is responsible to de-authenticating my request. From the time it leaves my middleware, to the time OnConnectedAsync is called, something removes the authentication.
Any Ideas?
Sample Code:
public class MyMiddleware
{
private readonly RequestDelegate _next;
public MyMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
await this._next(context);
//At this point context.User.Identity.IsAuthenticated == true
}
}
public class TestHub: Hub
{
private readonly IHttpContextAccessor _contextAccessor;
public TestHub(IHttpContextAccessor contextAccessor)
{
_contextAccessor = contextAccessor;
}
public override async Task OnConnectedAsync()
{
//At this point _contextAccessor.HttpContext.User.Identity.IsAuthenticated is false
await Task.FromResult(1);
}
public Task Send(string message)
{
return Clients.All.InvokeAsync("Send", message);
}
[Authorize]
public Task SendAuth(string message)
{
return Clients.All.InvokeAsync("SendAuth", message + " Authed");
}
}
public class Startup
{
// This method gets called by the runtime. Use this method to add services to the container.
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<MyContext>(options => options.UseInMemoryDatabase(databaseName: "MyDataBase1"));
services.AddIdentity<Auth, MyRole>().AddEntityFrameworkStores<MyContext>().AddDefaultTokenProviders();
services.Configure<IdentityOptions>(options => {
options.Password.RequireDigit = false;
options.Password.RequiredLength = 3;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
options.Password.RequireLowercase = false;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30);
options.Lockout.MaxFailedAccessAttempts = 10;
options.User.RequireUniqueEmail = true;
});
services.AddSignalR();
services.AddTransient<TestHub>();
services.AddTransient<MyMiddleware>();
services.AddAuthentication();
services.AddAuthorization();
services.AddMvc();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseMiddleware<MyMiddleware>();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseAuthentication();
app.UseSignalR(routes =>
{
routes.MapHub<TestHub>("TestHub");
});
app.UseMvc(routes =>
{
routes.MapRoute(name: "default", template: "{controller=App}/{action=Index}/{id?}");
});
}
}
And this is the client code:
public async Task Test()
{
var cookieJar = new CookieContainer();
var handler = new HttpClientHandler
{
CookieContainer = cookieJar,
UseCookies = true,
UseDefaultCredentials = false
};
var client = new HttpClient(handler);
var json = JsonConvert.SerializeObject((new Auth { Name = "craig", Password = "12345" }));
var content = new StringContent(json, Encoding.UTF8, "application/json");
var result1 = await client.PostAsync("http://localhost:5000/api/My", content); //cookie created
var result2 = await client.PostAsync("http://localhost:5000/api/My/authtest", content); //cookie tested and works
var connection = new HubConnectionBuilder()
.WithUrl("http://localhost:5000/TestHub")
.WithConsoleLogger()
.WithMessageHandler(handler)
.Build();
connection.On<string>("Send", data =>
{
Console.WriteLine($"Received: {data}");
});
connection.On<string>("SendAuth", data =>
{
Console.WriteLine($"Received: {data}");
});
await connection.StartAsync();
await connection.InvokeAsync("Send", "Hello"); //Succeeds, no auth required
await connection.InvokeAsync("SendAuth", "Hello NEEDSAUTH"); //Fails, auth required
}
If you are using Core 2 try changing the order of UseAuthentication, place it before the UseSignalR method.
app.UseAuthentication();
app.UseSignalR...
Then inside the hub the Identity property shouldn't be null.
Context.User.Identity.Name
It looks like this is an issue in the WebSocketsTransport where we don't copy Cookies into the websocket options. We currently copy headers only. I'll file an issue to get it looked at.