Blazor WASM Authentication and Authorization on Components and Controllers - authentication

I am developing a Blazor WASM with authentication and authorization. The idea is that the user need to login in order to be able to view the Components of the Client Project but also to be able to consume data of Controllers from Server Project which are behind the /api.
Currently I have implemented the restriction on Client components:
<AuthorizeView>
<NotAuthorized>
<div class="row">
<div class="col-md-4">
<p>Please sign in to use the Platform...</p>
</div>
</div>
</NotAuthorized>
<Authorized>
#Body
</Authorized>
</AuthorizeView>
I have also a Login and a Logout Page which are storing a Cookie for later use and perform a custom AuthenticationStateProvider
await LocalStorage.SetItemAsync<int>($"{Parameters.application}_{Parameters.enviroment}_userid", authentication.user_id);
await LocalStorage.SetItemAsync<string>($"{Parameters.application}_{Parameters.enviroment}_username", authentication.user_name);
await AuthStateProvider.GetAuthenticationStateAsync();
The AuthenticationStateProvider code is the following:
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var state = new AuthenticationState(new ClaimsPrincipal());
string authcookie_name = $"{Parameters.application}_{Parameters.enviroment}_username";
string authcookie_value = await _localStorage.GetItemAsStringAsync(authcookie_name);
if (!string.IsNullOrEmpty(authcookie_value))
{
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Authentication, authcookie_value)
}, "Login");
state = new AuthenticationState(new ClaimsPrincipal(identity));
}
NotifyAuthenticationStateChanged(Task.FromResult(state));
return state;
}
The authentication controller is the following:
[HttpPost, Route("/api/auth/login")]
public IActionResult AuthLogin(Authentication authentication)
{
try
{
int auth = _IAuth.AuthLogin(authentication);
if (auth != -1)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.Authentication, authentication.user_name)
};
var claimsIdentity = new ClaimsIdentity(claims, "Login");
var properties = new AuthenticationProperties()
{
IsPersistent = true
};
HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity), properties);
}
return Ok(auth);
}
catch { throw; }
}
Everything is working as excepted and the user need to login in order to see the content of the pages, but he is able to see the data of each page if he perform an http call http://domain.ext/api/model/view
In order to resolve this problem I added the Authorize attribute on each controller of Server project like this:
[Authorize]
[Route("/api/model")]
[ApiController]
public class Controller_Model : ControllerBase
{
}
And also added this code on the Program.cs of Server project in order to be able to make controller to work
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
{
options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
options.SlidingExpiration = true;
options.LoginPath = new PathString("/auth/login");
options.LogoutPath = new PathString("/auth/logout");
options.Cookie = new CookieBuilder();
options.Cookie.MaxAge = options.ExpireTimeSpan;
options.AccessDeniedPath = "/";
options.EventsType = typeof(CustomCookieAuthenticationEvents);
});
Now the user is not able to see the content of a page even he is making a request to the /api.
The problem is that after some time, even I see the User is still logged in the Authorize attribute of controllers is consider the user not authorized and it returns an error because controller is not returning the supposed object list.
I have no clue why and when this is happening. Then if user Logout and Login again it works for a while again.
===============UPDATE 1===============
After lot of investigation, seems that the client side is authenticated and then every time it sees the localstorage item it continues to be in authenticated state. On the other side the server state is based on a cookie which expires after 30mins.
So the Client and the Server states are operated differently and that's why the Client seems authenticated while Server is not while denying access on controllers.
I think the solution is to change the CustomAuthenticationStateProvider in order to check if the cookie exists and if it's valid. So the event order be as follow:
User SingIn via Client Page -> Server Controller creates the cookie -> Client Page is authenticated via Authentication State Provider which reads the cookie.
Any ideas?

Seems that is possible to read and write cookies from Client Project only via Javascript. What needs to be done is the following:
A custom javascript file "cookie.js", under wwwroot/js:
export function get() {
return document.cookie;
}
export function set(key, value) {
document.cookie = `${key}=${value}`;
}
A C# class file "CookieStorageAccessor.cs", under /Classes:
public class CookieStorageAccessor
{
private Lazy<IJSObjectReference> _accessorJsRef = new();
private readonly IJSRuntime _jsRuntime;
public CookieStorageAccessor(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
private async Task WaitForReference()
{
if (_accessorJsRef.IsValueCreated is false)
{
_accessorJsRef = new(await _jsRuntime.InvokeAsync<IJSObjectReference>("import", "/js/cookie.js"));
}
}
public async ValueTask DisposeAsync()
{
if (_accessorJsRef.IsValueCreated)
{
await _accessorJsRef.Value.DisposeAsync();
}
}
public async Task<T> GetValueAsync<T>(string key)
{
await WaitForReference();
var result = await _accessorJsRef.Value.InvokeAsync<T>("get", key);
return result;
}
public async Task SetValueAsync<T>(string key, T value)
{
await WaitForReference();
await _accessorJsRef.Value.InvokeVoidAsync("set", key, value);
}
}
The C# class can be used injecting javascript and reading the cookie on
CustomAuthStateProvider:
//CREATE INSTANCE OF COOKIE ACCESSOR
CookieStorageAccessor cookieStorageAccessor = new CookieStorageAccessor(_jSRuntime);
//CHECK IF COOKIE IS EXISTS FROM COOKIE ACCESSOR
string auth_cookie = await cookieStorageAccessor.GetValueAsync<string>("authentication");
if (!string.IsNullOrEmpty(auth_cookie))
{ }
else
{ }

Related

Blazor - B2C authentication - What is the proper way to persist user data on login?

I'm building a Blazor app to see how I can persist user data after a B2C AD login.
I want to persist claim data to sql database (ef 6 core) when the user logs in to the app.
I'm trying to capture a Tenant for the user for use in filtering on the app.
I is custom middleware a good way to go with this?
This is a Blazor Server Side app
I have something like this for testing.
public class PersistUserChangesMiddleware
{
private readonly RequestDelegate _next;
public PersistUserChangesMiddleware(RequestDelegate next)
{
_next = next;
}
[Authorize]
public Task Invoke(HttpContext httpContext, MyContext context)
{
try
{
var user = httpContext.User;
var claims = user.Claims;
var tenant = claims?.FirstOrDefault(c => c.Type.Equals("extension_CompanyId", StringComparison.OrdinalIgnoreCase));
if(tenant != null)
{
context.Tenants.Add(new Models.Tenant()
{
TenantName = tenant.Value
});
context.SaveChanges();
}
}
catch (Exception)
{
throw;
}
return _next(httpContext);
}
}
}
I'm not getting the user back from this call in the middleware. Do I need to do it a different way for Blazor? I set [Authorize] but still no user.
I can't see which AuthenticationStateProvider is configured, but it's likely to be ServerAuthenticationStateProvider.
Create a custom AuthenticationStateProvider which is essentially a pass through provider that just grabs the ClaimsPrincipal user and does whatever you want with it. (Let me know if you're using a different provider).
public class MyAuthenticationStateProvider : ServerAuthenticationStateProvider
{
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var authstate = await base.GetAuthenticationStateAsync();
if (authstate.User is not null)
{
ClaimsPrincipal user = authstate.User;
// do stuff with the ClaimsPrincipal
}
return authstate;
}
}
And then register it in Program:
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
// sequence is crucial - Must be after AddServerSideBlazor
builder.Services.AddScoped<AuthenticationStateProvider, MyAuthenticationStateProvider>();
builder.Services.AddSingleton<WeatherForecastService>();
Test it with a break point on the first line of GetAuthenticationStateAsync.

Customizing the AuthenticationStateProvider in Blazor Server App with Jwt Token Authentication

I've noticed that many developers subclass the AuthenticationStateProvider both in
Blazor Server App and Blazor WebAssembly App wrongly, and more imprtantly for the wrong
reasons.
How to do it correctly and when ?
First off, you do not subclass the AuthenticationStateProvider for the sole purpose of
adding claims to the ClaimPrincipal object. Generally speaking, claims are added after a
user has been authenticated, and if you need to inspect those claims and tranform them, it
should be done somewhere else, not in the AuthenticationStateProvider object. Incidentally, in
Asp.Net Core there are two ways how you can do that, but this merits a question of its own.
I guess that this code sample led many to believe that this is the place to add claims to the ClaimsPrincipal object.
In the current context, implementing Jwt Token Authentication, claims should be added
to the Jwt Token when it is created on the server, and extracted on the client when required,
as for instance, you need the name of the current user. I've noticed that developers save
the name of the user in the local storage, and retrieved it when needed. This is wrong.
You should extract the name of the user from the Jwt Token.
The following code sample describes how to create a custom AuthenticationStateProvider object
whose objective is to retrieve from the local storage a Jwt Token string that has newly added,
parse its content, and create a ClaimsPrincipal object that is served to interested
parties (subscribers to the AuthenticationStateProvider.AuthenticationStateChanged event)
, such as the CascadingAuthenticationState object.
The following code sample demonstrates how you can implement a custom
authenticationstateprovider properly, and for good reason.
public class TokenServerAuthenticationStateProvider :
AuthenticationStateProvider
{
private readonly IJSRuntime _jsRuntime;
public TokenServerAuthenticationStateProvider(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task<string> GetTokenAsync()
=> await _jsRuntime.InvokeAsync<string>("localStorage.getItem", "authToken");
public async Task SetTokenAsync(string token)
{
if (token == null)
{
await _jsRuntime.InvokeAsync<object>("localStorage.removeItem", "authToken");
}
else
{
await _jsRuntime.InvokeAsync<object>("localStorage.setItem", "authToken", token);
}
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var token = await GetTokenAsync();
var identity = string.IsNullOrEmpty(token)
? new ClaimsIdentity()
: new ClaimsIdentity(ServiceExtensions.ParseClaimsFromJwt(token), "jwt");
return new AuthenticationState(new ClaimsPrincipal(identity));
}
}
And here's a code sample residing in the submit button of a Login page that
calls a Web Api endpoint where the user credentials are validated, after which
a Jwt Token is created and passed back to the calling code:
async Task SubmitCredentials()
{
bool lastLoginFailed;
var httpClient = clientFactory.CreateClient();
httpClient.BaseAddress = new Uri("https://localhost:44371/");
var requestJson = JsonSerializer.Serialize(credentials, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var response = await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Post, "api/user/login")
{
Content = new StringContent(requestJson, Encoding.UTF8, "application/json")
});
var stringContent = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<LoginResult>(stringContent, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
lastLoginFailed = result.Token == null;
if (!lastLoginFailed)
{
// Success! Store token in underlying auth state service
await TokenProvider.SetTokenAsync(result.Token);
NavigationManager.NavigateTo(ReturnUrl);
}
}
Point to note: TokenProvider is an instance of TokenServerAuthenticationStateProvider.
Its name reflects its functionality: handling the recieved Jwt Token, and providing
the Access Token when requested.
This line of code: TokenProvider.SetTokenAsync(result.Token); passes the Jwt Token
to TokenServerAuthenticationStateProvider.SetTokenAsync in which the token is sored
in the local storage, and then raises AuthenticationStateProvider.AuthenticationStateChanged
event by calling NotifyAuthenticationStateChanged, passing an AuthenticationState object
built from the data contained in the stored Jwt Token.
Note that the GetAuthenticationStateAsync method creates a new ClaimsIdentity object from
the parsed Jwt Token. All the claims added to the newly created ClaimsIdentity object
are retrieved from the Jwt Token. I cannot think of a use case where you have to create
a new claim object and add it to the ClaimsPrincipal object.
The following code is executed when an authenticated user is attempting to access
the FecthData page
#code
{
private WeatherForecast[] forecasts;
protected override async Task OnInitializedAsync()
{
var token = await TokenProvider.GetTokenAsync();
var httpClient = clientFactory.CreateClient();
httpClient.BaseAddress = new Uri("https://localhost:44371/");
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"api/WeatherForecast?startDate={DateTime.Now}"));
var stringContent = await response.Content.ReadAsStringAsync();
forecasts = JsonSerializer.Deserialize<WeatherForecast[]>(stringContent, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
}
}
Note that the first line of code: var token = await TokenProvider.GetTokenAsync(); retrieves
the Jwt Token stored in the local storage, and add it to the Authorization header of the request.
Hope this helps...
Edit
Note: ServiceExtensions.ParseClaimsFromJwt is a method that gets the Jwt token extracted from the local storage, and parse it into a collection of claims.
Your Startup class should be like this:
public void ConfigureServices(IServiceCollection services)
{
// Code omitted...
services.AddScoped<TokenServerAuthenticationStateProvider>();
services.AddScoped<AuthenticationStateProvider>(provider => provider.GetRequiredService<TokenServerAuthenticationStateProvider>());
}

IdentityServer4 losing original returnUrl when using External Login server

Unfortunately, given the size of the project, I can’t easily share a reproducible version. However, hopefully what I have below will shed some light on my issue and you’ll see where I made a mistake.
I have two sites, an ASP.Net Core MVC application and a Login server, also ASP.Net Core MVC. Let’s call them http://mvc.mysite.com and http://login.mysite.com. Neither are significantly different from the IdentityServer4 Quickstart #6. The only real difference is that I have implemented an external login provider for AzureAd. My code for that is below.
Scenario 1
Given an internal login flow, where the user uses an internal login page at http://login.mysite.com everything works fine.
User visits http://mvc.mysite.com/clients/client-page-1
User is redirected to http://login.mysite.com/Account/Login
User logs in with correct username/password
User is redirected to http://mvc.mysite.com/clients/client-page-1
Scenario 2
However, if the login server’s AccountController::Login() method determines there is a single ExternalLoginProvider and executes the line “return await ExternalLogin(vm.ExternalLoginScheme, returnUrl);” then the original redirectUrl is lost.
User visits http://mvc.mysite.com/clients/client-page-1
User is redirected to http://login.mysite.com/Account/Login (receiving the output of AccountController::ExternalLogin)
User is redirected to AzureAd External OIDC Provider
User logs in with correct username/password
User is redirected to http://login.mysite.com/Account/ExternalLoginCallback
User is redirected to http://mvc.mysite.com (Notice that the user is redirected to the root of the MVC site instead of /clients/client-page-1)
For Scenario 1:
Given the MVC site
When using the debugger to inspect the Context provided to the OpenIdConnectEvents (e.g. OnMessageReceived, OnUserInformationReceived, etc.)
Then all Contexts have a Properties object that contains a RedirectUri == “http://mvc.mysite.com/clients/client-page-1”
For Scenario 2:
Given the MVC site
When using the debugger to inspect the Context provided to the OpenIdConnectEvents (e.g. OnMessageReceived, OnUserInformationReceived, etc.)
Then all Contexts have a Properties object that contains a RedirectUri == “http://mvc.mysite.com” (missing the /client.client-page-1)
In my login server’s Startup.cs I have added this to ConfigureServices:
services.AddAuthentication()
.AddAzureAd(options =>
{
Configuration.Bind("AzureAd", options);
AzureAdOptions.Settings = options;
});
The implementation of AddAzureAd is as follows: (You’ll see options objects handed around, I have replaced all uses of options with constant values except for ClientId and ClientSecret).
public static class AzureAdAuthenticationBuilderExtensions
{
public static AuthenticationBuilder AddAzureAd(this AuthenticationBuilder builder, Action<AzureAdOptions> configureOptions)
{
builder.AddOpenIdConnect("AzureAd", "Azure AD", options =>
{
var opts = new AzureAdOptions();
configureOptions(opts);
var config = new ConfigureAzureOptions(opts);
config.Configure(options);
});
return builder;
}
private class ConfigureAzureOptions : IConfigureNamedOptions<OpenIdConnectOptions>
{
private readonly AzureAdOptions _azureOptions;
public ConfigureAzureOptions(AzureAdOptions azureOptions)
{
_azureOptions = azureOptions;
}
public ConfigureAzureOptions(IOptions<AzureAdOptions> azureOptions) : this(azureOptions.Value) {}
public void Configure(string name, OpenIdConnectOptions options)
{
Configure(options);
}
public void Configure(OpenIdConnectOptions options)
{
options.ClientId = _azureOptions.ClientId;
options.Authority = "https://login.microsoftonline.com/common"; //_azureOptions.Authority;
options.UseTokenLifetime = true;
options.CallbackPath = "/signin-oidc"; // _azureOptions.CallbackPath;
options.RequireHttpsMetadata = false; // true in production // _azureOptions.RequireHttps;
options.ClientSecret = _azureOptions.ClientSecret;
// Add code for hybridflow
options.ResponseType = "id_token code";
options.TokenValidationParameters = new IdentityModel.Tokens.TokenValidationParameters
{
// instead of using the default validation (validating against a single issuer value, as we do in line of business apps),
// we inject our own multitenant validation logic
ValidateIssuer = false,
};
// Subscribing to the OIDC events
options.Events.OnAuthorizationCodeReceived = OnAuthorizationCodeReceived;
options.Events.OnAuthenticationFailed = OnAuthenticationFailed;
}
/// <summary>
/// Redeems the authorization code by calling AcquireTokenByAuthorizationCodeAsync in order to ensure
/// that the cache has a token for the signed-in user.
/// </summary>
private async Task OnAuthorizationCodeReceived(AuthorizationCodeReceivedContext context)
{
string userObjectId = (context.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier"))?.Value;
var authContext = new AuthenticationContext(context.Options.Authority, new NaiveSessionCache(userObjectId, context.HttpContext.Session));
var credential = new ClientCredential(context.Options.ClientId, context.Options.ClientSecret);
var authResult = await authContext.AcquireTokenByAuthorizationCodeAsync(context.TokenEndpointRequest.Code,
new Uri(context.TokenEndpointRequest.RedirectUri, UriKind.RelativeOrAbsolute), credential, context.Options.Resource);
// Notify the OIDC middleware that we already took care of code redemption.
context.HandleCodeRedemption(authResult.AccessToken, context.ProtocolMessage.IdToken);
}
private Task OnAuthenticationFailed(AuthenticationFailedContext context)
{
throw context.Exception;
}
}
}
public class NaiveSessionCache : TokenCache
{
private static readonly object FileLock = new object();
string UserObjectId = string.Empty;
string CacheId = string.Empty;
ISession Session = null;
public NaiveSessionCache(string userId, ISession session)
{
UserObjectId = userId;
CacheId = UserObjectId + "_TokenCache";
Session = session;
this.AfterAccess = AfterAccessNotification;
this.BeforeAccess = BeforeAccessNotification;
Load();
}
public void Load()
{
lock (FileLock)
this.Deserialize(Session.Get(CacheId));
}
public void Persist()
{
lock (FileLock)
{
// reflect changes in the persistent store
Session.Set(CacheId, this.Serialize());
// once the write operation took place, restore the HasStateChanged bit to false
this.HasStateChanged = false;
}
}
// Empties the persistent store.
public override void Clear()
{
base.Clear();
Session.Remove(CacheId);
}
public override void DeleteItem(TokenCacheItem item)
{
base.DeleteItem(item);
Persist();
}
// Triggered right before ADAL needs to access the cache.
// Reload the cache from the persistent store in case it changed since the last access.
void BeforeAccessNotification(TokenCacheNotificationArgs args)
{
Load();
}
// Triggered right after ADAL accessed the cache.
void AfterAccessNotification(TokenCacheNotificationArgs args)
{
// if the access operation resulted in a cache update
if (this.HasStateChanged)
Persist();
}
}

TenantId Claim not being returned from IdentityServer4 to Client App

Following on from this question, I ended up using the HttpContext.SignInAsync(string subject, Claim[] claims) overload to pass the selected tenant id as a claim (defined as type "TenantId") after the user selects a Tenant.
I then check for this claim in my custom AccountChooserResponseGenerator class from this question to determine if the user needs to be directed to the Tenant Chooser page or not, as follows:
public override async Task<InteractionResponse> ProcessInteractionAsync(ValidatedAuthorizeRequest request, ConsentResponse consent = null)
{
var response = await base.ProcessInteractionAsync(request, consent);
if (response.IsConsent || response.IsLogin || response.IsError)
return response;
if (!request.Subject.HasClaim(c=> c.Type == "TenantId" && c.Value != "0"))
return new InteractionResponse
{
RedirectUrl = "/Tenant"
};
return new InteractionResponse();
}
The interaction is working and the user gets correctly redirected back to the Client app after selecting a Tenant.
However, on my client, I have the simple:
<dl>
#foreach (var claim in User.Claims)
{
<dt>#claim.Type</dt>
<dd>#claim.Value</dd>
}
</dl>
snippet from the IdentityServer4 quickstarts to show the claims, and sadly, my TenantId claim is not there.
I have allowed for it in the definition of my Client on my IdentityServer setup, as follows:
var client = new Client
{
... other settings here
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
IdentityServerConstants.StandardScopes.Phone,
"TenantId"
}
};
What am I missing in order for this TenantId claim to become visible in my Client application?
EDIT:
Based on #d_f's comments, I have now added TentantId to my server's GetIdentityResources(), as follows:
public static IEnumerable<IdentityResource> GetIdentityResources()
{
return new List<IdentityResource>
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResources.Email(),
new IdentityResources.Phone(),
new IdentityResource("TenantId", new[] {"TenantId"})
};
}
And I have edited the client's startup.ConfigureServices(IServiceCollection services) to request this additional scope, as follows:
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
//other settings not shown
options.Scope.Add("TenantId");
});
And still the only claims displayed on the client by the indicated snippet are:
Edit 2: Fixed!
Finally #RichardGowan's answer worked. And that is because (as brilliantly observed by #AdemCaglin) I was using IdentityServer's AspNetIdentity, which has it's own implementation of IProfileService, which kept dropping my custom TenantId claim, despite ALL these other settings).
So in the end, I could undo all those other settings...I have no mention of the TenantId claim in GetIdentityResources, no mention of it in AllowedScopes in the definition of the Client in my IdSrv, and no mention of it in the configuration of services.AddAuthentication on my client.
You will need to provide and register an implementation of IProfileService to issue your custom claim back to the client:
public class MyProfileService : IProfileService {
public MyProfileService() {
}
public Task GetProfileDataAsync(ProfileDataRequestContext context) {
// Issue custom claim
context.IssuedClaims.Add(context.Subject.Claims.First(c => c.Type ==
"TenantId"));
return Task.CompletedTask;
}
public Task IsActiveAsync(IsActiveContext context) {
context.IsActive = true;
return Task.CompletedTask;
}
}

How to redirect after Azure AD authentication to different controller action in ASP Net Core MVC

I have setup my ASP Net Core 2.0 project to authenticate with Azure AD (using the standard Azure AD Identity Authentication template in VS2017 which uses OIDC). Everything is working fine and the app returns to the base url (/) and runs the HomeController.Index action after authentication is successful.
However I now want to redirect to a different controller action (AccountController.CheckSignIn) after authentication so that I can check if the user already exists in my local database table and if not (ie it's a new user) create a local user record and then redirect to HomeController.Index action.
I could put this check in the HomeController.Index action itself but I want to avoid this check from running every time the user clicks on Home button.
Here are some code snippets which may help give clarity...
AAD settings in appsettings.json
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "<my-domain>.onmicrosoft.com",
"TenantId": "<my-tennant-id>",
"ClientId": "<my-client-id>",
"CallbackPath": "/signin-oidc" // I don't know where this goes but it doesn't exist anywhere in my app and authentication fails if i change it
}
I added a new action to my AccountController.CheckSignIn to handle this requirement but I cannot find a way to call it after authentication.
public class AccountController : Controller
{
// I want to call this action after authentication is successful
// GET: /Account/CheckSignIn
[HttpGet]
public IActionResult CheckSignIn()
{
var provider = OpenIdConnectDefaults.AuthenticationScheme;
var key = User.FindFirstValue(ClaimTypes.NameIdentifier);
var info = new ExternalLoginInfo(User, provider, key, User.Identity.Name);
if (info == null)
{
return BadRequest("Something went wrong");
}
var user = new ApplicationUser { UserName = User.Identity.Name };
var result = await _userManager.CreateAsync(user);
if (result.Succeeded)
{
result = await _userManager.AddLoginAsync(user, info);
if (!result.Succeeded)
{
return BadRequest("Something else went wrong");
}
}
return RedirectToAction(nameof(HomeController.Index), "Home");
}
// This action only gets called when user clicks on Sign In link but not when user first navigates to site
// GET: /Account/SignIn
[HttpGet]
public IActionResult SignIn()
{
return Challenge(
new AuthenticationProperties { RedirectUri = "/Account/CheckSignIn" }, OpenIdConnectDefaults.AuthenticationScheme);
}
}
I have found a way to make it work by using a redirect as follows...
Inside Startup
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Account}/{action=SignIn}/{id?}");
});
Inside AccountController
// GET: /Account/CheckSignIn
[HttpGet]
[Authorize]
public IActionResult CheckSignIn()
{
//add code here to check if AzureAD identity exists in user table in local database
//if not then insert new user record into local user table
return RedirectToAction(nameof(HomeController.Index), "Home");
}
//
// GET: /Account/SignIn
[HttpGet]
public IActionResult SignIn()
{
return Challenge(
new AuthenticationProperties { RedirectUri = "/Account/CheckSignIn" }, OpenIdConnectDefaults.AuthenticationScheme);
}
Inside AzureAdServiceCollectionExtensions (.net core 2.0)
private static Task RedirectToIdentityProvider(RedirectContext context)
{
if (context.Request.Path != new PathString("/"))
{
context.Properties.RedirectUri = new PathString("/Account/CheckSignIn");
}
return Task.FromResult(0);
}
The default behavior is: user will be redirected to the original page. For example, user is not authenticated and access Index page, after authenticated, he will be redirected to Index page; user is not authenticated and access Contact page, after authenticated, he will be redirected to Contact page.
As a workaround, you can modify the default website route to redirect user to specific controller/action:
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Account}/{action=CheckSignIn}/{id?}"
);
});
After your custom logic, you could redirect user to your truly default page(Home/Index).
I want to check if the user exists in my local database, not only when Sign in is selected, but also when any other link to my website is clicked which requires authentication.
After a lot of trial and error I found a solution. Not sure if it is the best solution, but it works.
Basically I use the Authorize attribute with a policy [Authorize(Policy = "HasUserId")] as described in Claims-based authorization in ASP.NET Core.
Now when the policy is not met, you can reroute to the register action.
A – very simplified – version of the AccountController would look like this (I use a LogOn action instead of SignIn to prevent conflicts with the AzureADB2C AccountController):
public class AccountController : Controller
{
public IActionResult AccessDenied([FromQuery] string returnUrl)
{
if (User.Identity.IsAuthenticated)
return RedirectToAction(nameof(Register), new { returnUrl });
return new ActionResult<string>($"Access denied: {returnUrl}").Result;
}
public IActionResult LogOn()
{
// TODO: set redirectUrl to the view you want to show when a registerd user is logged on.
var redirectUrl = Url.Action("Test");
return Challenge(
new AuthenticationProperties { RedirectUri = redirectUrl },
AzureADB2CDefaults.AuthenticationScheme);
}
// User must be authorized to register, but does not have to meet the policy:
[Authorize]
public string Register([FromQuery] string returnUrl)
{
// TODO Register user in local database and after successful registration redirect to returnUrl.
return $"This is the Account:Register action method. returnUrl={returnUrl}";
}
// Example of how to use the Authorize attribute with a policy.
// This action will only be executed whe the user is logged on AND registered.
[Authorize(Policy = "HasUserId")]
public string Test()
{
return "This is the Account:Test action method...";
}
}
In Startup.cs, in the ConfigureServices method, set the AccessDeniedPath:
services.Configure<CookieAuthenticationOptions>(AzureADB2CDefaults.CookieScheme,
options => options.AccessDeniedPath = new PathString("/Account/AccessDenied/"));
A quick-and-dirty way to implement the HasUserId policy is to add the UserId from your local database as a claim in the OnSigningIn event of the CookieAuthenticationOptions and then use RequireClaim to check for the UserId claim. But because I need my data context (with a scoped lifetime) I used an AuthorizationRequirement with an AuthorizationHandler (see Authorization Requirements):
The AuthorizationRequirement is in this case just an empty marker class:
using Microsoft.AspNetCore.Authorization;
namespace YourAppName.Authorization
{
public class HasUserIdAuthorizationRequirement : IAuthorizationRequirement
{
}
}
Implementation of the AuthorizationHandler:
public class HasUserIdAuthorizationHandler : AuthorizationHandler<HasUserIdAuthorizationRequirement>
{
// Warning: To make sure the Azure objectidentifier is present,
// make sure to select in your Sign-up or sign-in policy (user flow)
// in the Return claims section: User's Object ID.
private const string ClaimTypeAzureObjectId = "http://schemas.microsoft.com/identity/claims/objectidentifier";
private readonly IUserService _userService;
public HasUserIdAuthorizationHandler(IUserService userService)
{
_userService = userService;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, HasUserIdAuthorizationRequirement requirement)
{
// Load User Id from database:
var azureObjectId = context.User?.FindFirst(ClaimTypeAzureObjectId)?.Value;
var userId = await _userService.GetUserIdForAzureUser(azureObjectId);
if (userId == 0)
return;
context.Succeed(requirement);
}
}
_userService.GetUserIdForAzureUser searches for an existing UserId in the database, connected to the azureObjectId and returns 0 when not found or when azureObjectId is null.
In Startup.cs, in the ConfigureServices method, add the Authorization policy and the AuthorizationHandler:
services.AddAuthorization(options => options.AddPolicy("HasUserId",
policy => policy.Requirements.Add(new HasUserIdAuthorizationRequirement())));
// AddScoped used for the HasUserIdAuthorizationHandler, because it uses the
// data context with a scoped lifetime.
services.AddScoped<IAuthorizationHandler, HasUserIdAuthorizationHandler>();
// My custom service to access user data from the database:
services.AddScoped<IUserService, UserService>();
And finally, in _LoginPartial.cshtml change the SignIn action from:
<a class="nav-link text-dark" asp-area="AzureADB2C" asp-controller="Account" asp-action="SignIn">Sign in</a>
To:
<a class="nav-link text-dark" asp-controller="Account" asp-action="LogOn">Sign in</a>
Now, when the user is not logged on and clicks Sign in, or any link to an action or controller decorated with [Authorize(Policy="HasUserId")], he will first be rerouted to the AD B2C logon page. Then, after logon, when the user is already registered, he will be rerouted to the selected link. When not registered, he will be rerouted to the Account/Register action.
Remark: If using policies does not fit well for your solution, take a look at https://stackoverflow.com/a/41348219.