Custom AuthenticationStateProvider returns "empty user" - authentication

in our application I'd like to use the user management of our fat client. For this I have written a custom AuthenticationStateProvider:
public class MyAuthenticationStateProvider : ServerAuthenticationStateProvider, IAuthentorizationService, IDisposable
{
public MyAuthenticationStateProvider (IPermissionManager permissionManager)
{
//User management service of the fat client
_permissionManager = permissionManager;
}
public override Task<AuthenticationState> GetAuthenticationStateAsync()
{
if (_permissionManager.PermissionUser == null)
{
var emptyUser = new ClaimsPrincipal(new ClaimsIdentity(new Claim[0]));
return Task.FromResult(new AuthenticationState(emptyUser));
}
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, _permissionManager.PermissionUser.User.GetName())
}, "FatClientAuthentication");
var user = new ClaimsPrincipal(identity);
return Task.FromResult(new AuthenticationState(user));
}
public async Task<bool> LoginUser(string userName, string password)
{
//Login via WCF connection
var response = await _clientProxy.Login(new LoginRequest
{
LoginUserName = userName,
Password = password
});
response.LogExceptionIfFaulted(_logger);
if (response.Ok)
{
_permissionManager.Initialize(response.LoggedInUser);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
return response.Ok;
}
The login works fine. For testing purposes I always log in with fixed user credentials. After the successful login I fire the NotifyAuthenticationStateChanged event, which results in a correct call of the GetAuthenticationStateAsync method. The now logged in user is correctly wrapped inside the AuthenticationState. When debugging the code I can see that the Identity with the name claim is the correct user and the IsAuthenticated property is true.
However, when using the "AuthorizeView" component, I always get an "empty user" (no name claim, no user name, IsAuthenticated is false)
I now have a small component just for testing:
<AuthorizeView>
<Authorized>
<h2>User #context.User.Identity.Name</h2> is logged in!
Claims:
<ul>
#foreach (var claim in context.User.Claims)
{
<li>Type=#claim.Type; Value=#claim.Value</li>
}
</ul>
#context.User.Claims
<p>Current count: #currentCount</p>
<button class="btn btn-primary" #onclick="IncrementCount">Click me</button>
</Authorized>
<NotAuthorized>
<h2>User #context.User.Identity.Name</h2> #*this is an empty string*#
<h2>Authentication Type: #context.User.Identity.AuthenticationType</h2> #*empty*#
<h2>Authenticated: #context.User.Identity.IsAuthenticated</h2>#*false*#
No user is logged in!
</NotAuthorized>
Im using the AuthorizeRouteView and the CascadingAuthenticationState in the App.razor like in the official sample displayed in https://learn.microsoft.com/en-us/aspnet/core/security/blazor/?view=aspnetcore-3.1
Accessing the AuthenticationState via an CascadingParameter also results in the same "empty user".
Appreciate any help,
tilt32
EDIT 1
So I looked into the login behaviour again, making sure that the event is called.
I then figured out, that my AuthenticationStateChanged event has no subscribers (is null). My impression was, that something in the framework attaches to this event at startup. Maybe I did forget some configuration method call in the startup ? This is what I do in the configure services:
services.AddScoped<AuthenticationStateProvider, MyAuthenticationStateProvider>();
services.AddScoped<ServerAuthenticationStateProvider, MyAuthenticationStateProvider>();
//Interface which I use in my LoginCompontent and at Startup to log in with the default user or some real user credentials
services.AddScoped<IAuthenticationService, MyAuthenticationStateProvider>();
I also tried the approach suggested by user enet. Sadly with no success, the result was the same. During the login a call to NotifyAuthenticationStateChanged and hence to the event with no subscribers is done.
The WCF service we use in the background requires a logged in user. Hence i made a guest user with limited rights to solve this issue. So the app steps into the GetAuthenticationStateAsync and tries to fire the AuthenticationStateEvent directly after startup (during a loading screen).
EDIT 2
So I now tried some additional setup steps, from which Microsoft wrote in the Blazor documentation, that they should not be necessary for server-side blazor:
The ConfigureServices now looks like this
//Authentication & Authorization setup
services.AddOptions();
services.AddAuthenticationCore();
services.AddAuthorizationCore();
services.AddScoped<IPermissionManager, SingleUserPermissionManager>();
services.AddScoped<AuthenticationStateProvider, MyAuthenticationStateProvider>();
services.AddScoped<ServerAuthenticationStateProvider, MyAuthenticationStateProvider>();
services.AddScoped<IAuthenticationService, MyAuthenticationStateProvider>();
In the Configure(IApplicationBuilder app, IWebHostEnvironment env) Method, I added the following calls:
app.UseAuthentication();
app.UseAuthorization();
This did also have no effect.

I think the AuthenticationState object is not available because the AuthenticationStateChanged event is not invoked from the AuthenticationStateProvider, and thus your AuthorizeView and your CascadingAuthenticationState components are not aware of the state change. Check your logic once more in this direction. Also make sure that you properly add the subclassed provider to the DI container. I tend to believe that the issue is with this. Please, show all the relevant code from the ConfigureServices method.
Update:
Please, try this:
services.AddScoped<MyAuthenticationStateProvider>();
services.AddScoped<AuthenticationStateProvider>(provider =>
provider.GetRequiredService<MyAuthenticationStateProvider>());
Hope this helps...

Related

Blazor WASM standalone project reauthentication for multiple browser tabs after logout

I have implemented authorization with JWT auth on our blazor WASM project. All was well - logging in and logging out...until
Scenario:
I noticed that if I duplicated my 'logged in (authenticated identity user) tab', now having two valid authorized user tabs open. In the first tab I log out: I am re-directed to the login page, the stored token is gone, everything seems ok - but when I go to the second tab and click to go to a new page(within the tab) I am able to do so. When I debug and check the auth state.. it still has the validated identity user!
Expected Results:
When I logout in the first autenticated tab, I expect the auth state of the second tab to also be deauthenticated.
Error Messages:
Currently none
What I have tried:
I searched around for someone solving the same thing for Blazor WASM standalone apps. I did come across this guy's video where he describes most people are using the AuthenticationStateProvider wrong by injecting it directly instead of using a cascading parameter. He actually then demonstates the exact issue I am having - but then proceeds to solve it with a Balzor Server class library(using RevalidateServerAuthenticationStateProvider with some custom code on the ValidateAuthenticationStateAsync function)! Not a WASM solution! Link to his video: https://www.youtube.com/watch?v=42O7rECc87o&list=WL&index=37&t=952s - 13:23 he demonstrates the issue I have attempted to depict.
Code of my logout sequence:
To start - when you click the logout button - I redirect to a logout page. We will start here:
Logout.Razor.cs
[CascadingParameter]
public Task<AuthenticationState> AuthenticationStateTask { get; set; }
protected override async Task OnInitializedAsync()
{
await AuthService.Logout();
var authState = await AuthenticationStateTask;
var user = authState.User;
if (!user.Identity.IsAuthenticated)
{
NavManager.NavigateTo("/");
}
}
You can see you then are waiting for the AuthService to log you out.
AuthService.cs
private readonly AuthenticationStateProvider _authStateProvider;
public async Task Logout()
{
await _localStorage.RemoveItemAsync("authToken");
await ((CustomAuthStateProvider)_authStateProvider).NotifyUserLogout();
_client.DefaultRequestHeaders.Authorization = null;
}
Then you are waiting on the CustomAuthStateProvider:AuthenticationStateProdiver to notify that the state has changed.
CustomAuthStateProvider.cs
_anonymous = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
public async Task NotifyUserLogout()
{
var authState = Task.FromResult(_anonymous);
NotifyAuthenticationStateChanged(authState);
}
At this point in the active page we are logged out and redirected to the login page.
I was hoping there is some browser session option for the authstate I am missing or there is some microsoft documentation of handling this situation on WASM projects - but my peanut brain can't seem to find it.
Additional Information: I am purely only using the AuthenticationStateProdiver for a custom login.
I am not using OidcAuthentication or MsalAuthentication services.
This is a standlone WASM app with a completely decoupled API. All .net6 living on azure.
Looking forward to see if anyone else has this issue!
BR,
MP

OIDC authentication in server-side Blazor

I used this method but somehow it's not right because #attribute [AllowAnonymous] doesn't really worked so I use [Authorized] attribute instead of [AllowAnonymous] and then remove RequireAuthenticatedUser but OIDC does not redirect client to server login page.
I checked SteveSanderson github article about authentication and authorization in blazor but he didn't talk about OIDC.
So how can I handle this?
Startup class:
services.AddAuthentication(config =>
{
config.DefaultScheme = "Cookie";
config.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookie")
.AddOpenIdConnect("oidc", config =>
{
config.Authority = "https://localhost:44313/";
config.ClientId = "client";
config.ClientSecret = "secret";
config.SaveTokens = true;
config.ResponseType = "code";
config.SignedOutCallbackPath = "/";
config.Scope.Add("openid");
config.Scope.Add("api1");
config.Scope.Add("offline_access");
});
services.AddMvcCore(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser() // site-wide auth
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
});
The following is a complete and working solution to the question:
First off, you'll need to provide an authentication challenge request mechanism that enables redirection to an authenticating agent such as IdentityServer. This is only possible with HttpContext, which is not available in SignalR (Blazor Server App). To solve this issue we'll add a couple of Razor pages where the HttpContext is available. More in the answer...
Create a Blazor Server App.
Install-Package Microsoft.AspNetCore.Authentication.OpenIdConnect -Version 3.1.0 or later.
Create a component named LoginDisplay (LoginDisplay.razor), and place it in the
Shared folder. This component is used in the MainLayout component:
<AuthorizeView>
<Authorized>
Hello, #context.User.Identity.Name !
<form method="get" action="logout">
<button type="submit" class="nav-link btn btn-link">Log
out</button>
</form>
</Authorized>
<NotAuthorized>
Log in
</NotAuthorized>
</AuthorizeView>
Add the LoginDisplay component to the MainLayout component, just above the About
anchor element, like this
<div class="top-row px-4">
<LoginDisplay />
About
</div>
Note: In order to redirect requests for login and for logout to IdentityServer, we have to create two Razor pages as follows:
Create a Login Razor page Login.cshtml (Login.cshtml.cs) and place them in the Pages folder as follow:
Login.cshtml.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.IdentityModel.Tokens;
public class LoginModel : PageModel
{
public async Task OnGet(string redirectUri)
{
await HttpContext.ChallengeAsync("oidc", new
AuthenticationProperties { RedirectUri = redirectUri } );
}
}
This code starts the challenge for the Open Id Connect authentication scheme you defined in the Startup class.
Create a Logout Razor page Logout.cshtml (Logout.cshtml.cs) and place them in the Pages folder as well:
Logout.cshtml.cs
using Microsoft.AspNetCore.Authentication;
public class LogoutModel : PageModel
{
public async Task<IActionResult> OnGetAsync()
{
await HttpContext.SignOutAsync();
return Redirect("/");
}
}
This code signs you out, redirecting you to the Home page of your Blazor app.
Replace the code in App.razor with the following code:
#inject NavigationManager NavigationManager
<CascadingAuthenticationState>
<Router AppAssembly="#typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="#routeData" DefaultLayout="#typeof(MainLayout)">
<NotAuthorized>
#{
var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
NavigationManager.NavigateTo($"login?redirectUri={returnUrl}", forceLoad: true);
}
</NotAuthorized>
<Authorizing>
Wait...
</Authorizing>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="#typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
Replace the code in the Startup class with the following:
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// 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.AddRazorPages();
services.AddServerSideBlazor();
services.AddAuthorizationCore();
services.AddSingleton<WeatherForecastService>();
services.AddAuthentication(sharedOptions =>
{
sharedOptions.DefaultAuthenticateScheme =
CookieAuthenticationDefaults.AuthenticationScheme;
sharedOptions.DefaultSignInScheme =
CookieAuthenticationDefaults.AuthenticationScheme;
sharedOptions.DefaultChallengeScheme =
OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://demo.identityserver.io/";
options.ClientId = "interactive.confidential.short";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.UseTokenLifetime = false;
options.Scope.Add("openid");
options.Scope.Add("profile");
options.TokenValidationParameters = new
TokenValidationParameters
{
NameClaimType = "name"
};
options.Events = new OpenIdConnectEvents
{
OnAccessDenied = context =>
{
context.HandleResponse();
context.Response.Redirect("/");
return Task.CompletedTask;
}
};
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapBlazorHub();
endpoints.MapFallbackToPage("/_Host");
});
}
}
IMPORTANT: in all the code sample above you'll have to add using statements as necessary. Most of them are provided by default. The using provided here are those necessary to enable the authentication and authorization flow.
Run your app, click on the log in button to authenticate. You are being redirected to IdentityServer test server which allows you to perform an OIDC login. You may enter a user name: bob and password bob, and after click the OK button, you'll be redirected to your home page. Note also that you can use the external login provider Google (try it). Note that after you've logged in with identity server, the LoginDisplay component displays the string "Hello, <your user name>".
Note: While you're experimenting with your app, you should clear the browsing data, if you want to be redirected to the identity server's login page, otherwise, your browser may use the cached data. Remember, this is a cookie-based authorization mechanism...
Note that creating a login mechanism as is done here does not make your app more secured than before. Any user can access your web resources without needing to log in at all. In order to secure parts of your web site, you have to implement authorization as well, conventionally, an authenticated user is authorized to access secured resource, unless other measures are implemented, such as roles, policies, etc. The following is a demonstration how you can secure your Fetchdata page from unauthorized users (again, authenticated user is considered authorized to access the Fetchdata page).
At the top of the Fetchdata component page add the #attribute directive for the Authorize attribute, like this: #attribute [Authorize]
When an unauthenticated user tries to access the Fetchdata page, the AuthorizeRouteView.NotAuthorized delegate property is executed, so we can add some code to redirect the user to the same identity server's login page to authenticate.
The code within the NotAuthorized element looks like this:
<NotAuthorized>
#{
var returnUrl =
NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
NavigationManager.NavigateTo($"login?redirectUri=
{returnUrl}", forceLoad: true);
}
</NotAuthorized>
This retrieves the url of the last page you were trying to access, the FetchData page, and then navigates to the Login Razor page from which a password challenge is performed, that is, the user is redirected to the identity server's login page to authenticate.
After the user has authenticated they are redirected to the FetchData page.
For server-side Blazor, authentication happens on the Razor page on which the Blazor application is hosted. For the default template, this is the _Host.cshtml Razor page which is configured to be the fallback page for server-side routing. Since the page is like a normal Razor page, you can use the [Authorize] or [AllowAnonymous] attributes there.
Any authorization you apply to the _Host.cshtml impacts how the general access to the Blazor app itself is authorized. If you want only authenticated users to access the app, you should require authorization; if you want any non-authenticated users to access the app, you cannot protect the app access itself.
The authorization of the page does not mean that you cannot have a more fine-grained authorization within your app. You can still use different rules and policies for particular components within your application. For that, you can use the <AuthorizeView> component.
There are two common scenarios that are likely for server-side Blazor:
Access to the whole Blazor application is limited to authenticated users. Users that are not authenticated should immediately authenticate (e.g. using OIDC) so that no anonymous user hits the app.
In that case, it should be enough to protect the _Host.cshtml by requiring authenticated users, either through the [Authorize] attribute, or using a convention in the AddRazorPages() call.
When accessing the Blazor application without being authenticated, the default authorization middleware will cause an authentication challenge and redirect to the OIDC sign-in.
Non-authenticated users should be able to access the Blazor application but the Blazor application will use a more detailed authorization using the <AuthorizeView> or IAuthorizationService.
In this situation, the _Host.cshtml must not be protected since anonymous users need to access it. This also means that the default authorization middleware, which runs as part of the Razor page, will not do anything. So you will have to handle the challenge yourself.
The “simple” way to do this would be to provide a login link to a different server-side route which will then trigger the authentication challenge and redirect to the OIDC sign-in. For example, you could have a MVC action like this:
[HttpGet("/login")]
public IActionResult Login()
=> Challenge();
Within your Blazor app, you could now add a link to this route and allow users to sign in that way:
<AuthorizeView>
<Authorized>
Signed in as #context.User.Identity.Name.
</Authorized>
<NotAuthorized>
Sign in here
</NotAuthorized>
</AuthorizeView>

Restrict account registration to only Admin users with asp.net identity authentication

I am creating a Blazor server app that requires authenticated users in order to prevent external access, and I would like to limit the ability to register new accounts to be only available to Administrator users to prevent unwanted accounts from being created.
I'm using Identity user accounts, scaffolded out for Blazor. Solutions like this at least disable the registration, but from there I need to be able to enable it again for administrative users. I attempted to recreate the register page as a Blazor component, however, using the generated RegisterModel did not seem to work for me.
Upon a large amount of searching - the answer ended up being relatively simple. Muhammad Hammad Maroof's solution although technically correct, confused me and was mostly unhelpful for working with the register page specifically.
As I am using Role-Based Authentication scaffolded out from Blazor - in a seperate razor page I use this code to set up roles:
#code {
protected override async Task OnParametersSetAsync()
{
await SetUpAuth();
}
private async Task SetUpAuth()
{
const string Manager = "Manager";
string[] roles = { Manager };
foreach (var role in roles)
{
var roleExist = await roleManager.RoleExistsAsync(role);
if (!roleExist)
{
await roleManager.CreateAsync(new IdentityRole(role));
}
}
var user = await userManager.FindByEmailAsync(config.GetValue<string>("AdminUser"));
if (user != null)
{
await userManager.AddToRoleAsync(user, Manager);
}
}
}
Allowing the appropriate user to be marked as an administrator. This page has the [AllowAnonymous] tag on it in order to allow the administrative user as dictated by "AdminUser": "SomeEmail#test.com", in the appsettings.json page to be able to access the site on initial setup.
Preventing access to the Blazor site itself from anonymous users was as simple as adding this line to ConfigureServices in the startup class (Code taken from Microsoft Docs)
services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
From this, allowing access to the register page was significantly easier than I had initially thought (likely due to my lack of .net experience). To do so, all you have to do is locate the Register.cshtml.cs page (I couldn't initially find the controller method Muhammad had mentioned) which I did by using visual studio to right click on the Register Model and then go to definition. This should take you to the Register.cshtml.cs page with the RegisterModel class. In order to restrict access to this page for only a specific role of users, all you have to do is change the [AllowAnonymous] tag above the class to look similar to this:
[Authorize(Roles ="Manager")]
public class RegisterModel : PageModel
It's important to note that the same technique used to secure the register page could be used to secure any of the of the other scaffolded Identity pages. For applications where you may have more than a few roles, the method provided by Muhammad of using policy based authorization may be the way to go, and this link he provided is a great tutorial for setting up and using that form of authentication.
//FORCE autentication for all RAZOR PAGES except [AllowAnonymous]
services.AddControllers(config => {
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
config.Filters.Add(new AuthorizeFilter(policy));
});
Only adding this code to my startup.cs solved my problem.
Here's how I am doing it in asp.net core mvc app
C# Startup class:
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthorization(options =>
{
options.AddPolicy(ADMIN_ACCESS, policy => policy.RequireRole($"{UserType.Admin}"));
});
}
[Authorize("AdminAccess")]
public class AdminController : Controller
{
//Some action methods here
}

ASP.NET Core - use external provider but *keep* Default Identity database

I want the database that comes with the Default Identity provider in ASP.NET Core. However, I'd like users to login exclusively with their Microsoft account.
So at the moment, I have this in my user LoginDisplay.razor file:
<AuthorizeView>
<Authorized>
Hello, #context.User.Identity.Name!
Log out
</Authorized>
<NotAuthorized>
Register
Log in
</NotAuthorized>
</AuthorizeView>
When the user clicks "Log in" they're taken to the regular login form:
Here they can click on the "Microsoft Account" button. What I would like to do is skip the default login screen and go directly to the Microsoft Account workflow.
How would I do that?
Keeping the identity database offers me a couple of benefits:
I plan to add more data to the database - so it's handy if I can refer to accounts that exist in the same database
It's possible that I may need to give users access to the site that do not have a Microsoft account
Update
Based on feedback, I've implemented the following:
#inject Data.Services.AntiForgery antiforgery;
<form id="external-account" method="post" class="inline-block form-horizontal" action="/Identity/Account/ExternalLogin?returnUrl=%2F">
<button type="submit" name="provider" value="microsoft" title="Log in using your Microsoft Account account">Login</button>
<input name="__RequestVerificationToken" type="hidden" value="#antiforgery.Generate()">
</form>
And here's my utility class that I used to work around the anti-forgery request token (in Blazor):
public class AntiForgery
{
public IAntiforgery Antiforgery { get; private set; }
public IHttpContextAccessor Accessor { get; private set; }
public AntiForgery( IAntiforgery antiforgery, IHttpContextAccessor accessor )
{
Antiforgery = antiforgery;
Accessor = accessor;
}
public string Generate()
{
// Code stolen from:
// * https://stackoverflow.com/questions/45254196/asp-net-core-mvc-anti-forgery; and
// * https://stackoverflow.com/questions/53817373/how-do-i-access-httpcontext-in-server-side-blazor
return Antiforgery.GetAndStoreTokens( Accessor.HttpContext ).RequestToken;
}
}
For the utility class to work, the following was added to my Startup file:
services.AddSingleton<AntiForgery>();
services.AddHttpContextAccessor();
Well, you can simply just hide the login form itself, showing only the Microsoft Account button. However, it is not possible to send the user directly into that flow. It requires an initial post, which is going to require action on the user's part, i.e. clicking the button.
For what it's worth. If you have a "Login" type link, you can code this in the same way as the Microsoft Account button, so that it then initiates the flow when the user clicks "Login". However, you'll still need an actual login page to redirect to for authorization failures, and that would still require an explicit button press there.
You could directly pass the provider name Microsoft to your external login function using asp-route-provider.
For asp.net core 2.2+, Identity is scaffolded into identity area with Razor Pages.
1.Login link.
<a asp-area="Identity" asp-page="/Account/ExternalLogin" asp-page-handler="TestExternal" asp-route-provider="Microsoft">Log in</a>
2.ExternalLogin.cshtml.cs
public IActionResult OnGetTestExternalAsync(string provider, string returnUrl = null)
{
var redirectUrl = Url.Page("./ExternalLogin", pageHandler: "Callback", values: new { returnUrl });
var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return new ChallengeResult(provider, properties);
}
3.Startup.cs
services.AddAuthentication().AddMicrosoftAccount(microsoftOptions =>
{
//use your own Id and secret
microsoftOptions.ClientId = Configuration["Authentication:Microsoft:ClientId"];
microsoftOptions.ClientSecret = Configuration["Authentication:Microsoft:ClientSecret"];
});

How to prevent multiple login in SAAS application?

What I need to do
I'm developing an application using ASP.NET CORE and I actually encountered a problem using the Identity implementation.
In the official doc infact there is no reference about the multiple session, and this is bad because I developed a SaaS application; in particular a user subscribe a paid plan to access to a specific set of features and him can give his credentials to other users so they can access for free, this is a really bad scenario and I'll lose a lot of money and time.
What I though
After searching a lot on the web I found many solutions for the older version of ASP.NET CORE, so I'm not able to test, but I understood that the usually the solution for this problem is related to store the user time stamp (which is a GUID generated on the login) inside the database, so each time the user access to a restricted page and there are more session (with different user timestamp) the old session will closed.
I don't like this solution because an user can easily copy the cookie of the browser and share it will other users.
I though to store the information of the logged in user session inside the database, but this will require a lot of connection too.. So my inexperience with ASP.NET CORE and the lack of resource on the web have sent me in confusion.
Someone could share a generic idea to implement a secure solution for prevent multiple user login?
I've created a github repo with the changes to the default .net core 2.1 template needed to only allow single sessions. https://github.com/xKloc/IdentityWithSession
Here is the gist.
First, override the default UserClaimsPrincipalFactory<IdentityUser> class with a custom one that will add your session to the user claims. Claims are just a key/value pair that will be stored in the user's cookie and also on the server under the AspNetUserClaims table.
Add this class anywhere in your project.
public class ApplicationClaimsPrincipalFactory : UserClaimsPrincipalFactory<IdentityUser>
{
private readonly UserManager<IdentityUser> _userManager;
public ApplicationClaimsPrincipalFactory(UserManager<IdentityUser> userManager, IOptions<IdentityOptions> optionsAccessor) : base(userManager, optionsAccessor)
{
_userManager = userManager;
}
public async override Task<ClaimsPrincipal> CreateAsync(IdentityUser user)
{
// find old sessions and remove
var claims = await _userManager.GetClaimsAsync(user);
var session = claims.Where(e => e.Type == "session");
await _userManager.RemoveClaimsAsync(user, session);
// add new session claim
await _userManager.AddClaimAsync(user, new Claim("session", Guid.NewGuid().ToString()));
// create principal
var principal = await base.CreateAsync(user);
return principal;
}
}
Next we will create an authorization handler that will check that the session is valid on every request.
The handler will match the session claim from the user's cookie to the session claim stored in the database. If they match, the user is authorized to continue. If they don't match, the user will get a Access Denied message.
Add these two classes anywhere in your project.
public class ValidSessionRequirement : IAuthorizationRequirement
{
}
public class ValidSessionHandler : AuthorizationHandler<ValidSessionRequirement>
{
private readonly UserManager<IdentityUser> _userManager;
private readonly SignInManager<IdentityUser> _signInManager;
public ValidSessionHandler(UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager)
{
_userManager = userManager ?? throw new ArgumentNullException(nameof(userManager));
_signInManager = signInManager ?? throw new ArgumentNullException(nameof(signInManager));
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, ValidSessionRequirement requirement)
{
// if the user isn't authenticated then no need to check session
if (!context.User.Identity.IsAuthenticated)
return;
// get the user and session claim
var user = await _userManager.GetUserAsync(context.User);
var claims = await _userManager.GetClaimsAsync(user);
var serverSession = claims.First(e => e.Type == "session");
var clientSession = context.User.FindFirst("session");
// if the client session matches the server session then the user is authorized
if (serverSession?.Value == clientSession?.Value)
{
context.Succeed(requirement);
}
return;
}
}
Finally, just register these new classes in start up so they get called.
Add this code to your Startup class under the ConfigureServices method, right below services.AddDefaultIdentity<IdentityUser>()
.AddEntityFrameworkStores<ApplicationDbContext>();
// build default authorization policy
var defaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new ValidSessionRequirement())
.Build();
// add authorization to the pipe
services.AddAuthorization(options =>
{
options.DefaultPolicy = defaultPolicy;
});
// register new claims factory
services.AddScoped<IUserClaimsPrincipalFactory<IdentityUser>, ApplicationClaimsPrincipalFactory>();
// register valid session handler
services.AddTransient<IAuthorizationHandler, ValidSessionHandler>();
You can use UpdateSecurityStamp to invalidate any existing authentication cookies. For example:
public async Task<IActionResult> Login(LoginViewModel model)
{
var user = await _userManager.FindByEmailAsync(model.Email);
if (user == null)
{
ModelState.AddModelError(string.Empty, "Invalid username/password.");
return View();
}
if (await _userManager.ValidatePasswordAsync(user, model.Password))
{
await _userManager.UpdateSecurityStampAsync(user);
var result = await _signInManager.SignInAsync(user, isPersistent: false);
// handle `SignInResult` cases
}
}
By updating the security stamp will cause all existing auth cookies to be invalid, basically logging out all other devices where the user is logged in. Then, you sign in the user on this current device.
Best way is to do something similar to what Google, Facebook and others do -- detect if user is logging in from a different device. For your case, I believe you would want to have a slight different behavior -- instead of asking access, you'll probably deny it. It's almost like you're creating a license "per device", or a "single tenant" license.
This Stack Overflow thread talks about this solution.
The most reliable way to detect a device change is to create a
fingerprint of the browser/device the browser is running on. This is a
complex topic to get 100% right, and there are commercial offerings
that are pretty darn good but not flawless.
Note: if you want to start simple, you could start with a Secure cookie, which is less likely to be exposed to cookie theft via eavesdropping. You could store a hashed fingerprint, for instance.
There are some access management solutions (ForgeRock, Oracle Access Management) that implement this Session Quota functionality. ForgeRock has a community version and its source code is available on Github, maybe you can take a look at how it is implemented there. There is also a blog post from them giving a broad view of the functionality (https://blogs.forgerock.org/petermajor/2013/01/session-quota-basics/)
If this is too complex for your use case, what I would do is combine the "shared memory" approach that you described with an identity function, similar to what Fabio pointed out in another answer.