A single login page for multiple authentication types (including Azure AD) in .NET Core MVC - authentication

I have a .NET Core 2.2 MVC web-application. And I've added two authentication types/providers there:
Login/password with local users database (custom thing, without .NET Core Identity)
Azure AD
My goal is to have a login page at /account/login where users can choose between these two authentications and log-in with either of those. So every time an unauthenticated user would open any page (from a controller with [Authorize] attrubite), he would get redirected to /account/login page, which has a login/password web-form with its own submit button, and additionally a Office 365 login link/button.
Just to make it clear - I don't want a custom Microsoft sign-in / Azure AD page. I only want unauthenticated users to get my login page first, from where they can either log-in using my web-form or click on Office 365 login and get to Microsoft sign-in page.
Now, the authentication part is done and seems to work fine, I can log-in with either of authentications, but my plan with redirecting unauthenticated user to /account/login failed. What happens instead is that user is being redirected to Microsoft sign-in page right away. So it looks like Azure AD authentication has a higher priority somehow.
Here's my implementation.
Startup.cs:
// ...
public void ConfigureServices(IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
{
options.MinimumSameSitePolicy = SameSiteMode.None;
});
// the presence of CookieAuthenticationDefaults.AuthenticationScheme doesn't seem to influence anything
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
// makes no difference either
//services.AddAuthentication(
// options =>
// {
// options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// }
//)
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, // and it also can be omitted here
options =>
{
options.LoginPath = "/Account/Login";
options.ExpireTimeSpan = TimeSpan.FromDays(45);
})
.AddAzureAD(options => _configuration.Bind("AzureAD", options));
services.AddAuthorization(options =>
{
// as the default policy, it applies to all [Authorize] controllers
options.DefaultPolicy = new AuthorizationPolicyBuilder(
CookieAuthenticationDefaults.AuthenticationScheme,
AzureADDefaults.AuthenticationScheme
)
.RequireAuthenticatedUser() // a simple policy that only requires a user to be authenticated
.Build();
});
services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options =>
{
options.Authority = options.Authority + "/v2.0/";
options.TokenValidationParameters.ValidateIssuer = false;
});
services.AddMvc(options =>
{
// it is my understanding that there is no need create a policy here
// and perform "options.Filters.Add(new AuthorizeFilter(policy))",
// because the default policy is already added and controllers have explicit [Authorize] attribute
// [...] well, actually I tried that too, but it didn't change anything
options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
})
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}
public void Configure(
IApplicationBuilder app,
IHostingEnvironment env,
ILoggerFactory loggerFactory
)
{
// ...
app.UseCookiePolicy();
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}"
);
});
}
AccountController.cs:
[Authorize]
[Route("account")]
public class AccountController : Controller
{
// ...
// that is where "Office 365 login" link leads
[HttpGet("login-ad")]
[AllowAnonymous]
public IActionResult LoginAD(string returnUrl = null)
{
if (User.Identity.IsAuthenticated)
{
return RedirectToAction("Index", "Account");
}
else
{
if (string.IsNullOrEmpty(returnUrl)) { returnUrl = "/"; }
return Challenge(
new AuthenticationProperties { RedirectUri = returnUrl },
AzureADDefaults.AuthenticationScheme
);
}
}
[HttpGet("login")]
[AllowAnonymous]
public IActionResult Login(string returnUrl = null)
{
if (User.Identity.IsAuthenticated)
{
return RedirectToAction("Index", "Account");
}
ViewData["ReturnUrl"] = returnUrl;
return View();
}
// that is where login/password web-form submits to
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginViewModel model, string returnUrl = null)
{
if (ModelState.IsValid)
{
await _usersManager.SignIn(
model.Login,
model.Password
);
// ...
return LocalRedirect(returnUrl);
}
ViewData["ReturnUrl"] = returnUrl;
return View(model);
}
// ...
}
HomeController.cs:
[Authorize]
public class HomeController : Controller
{
// ...
public IActionResult Index()
{
return View();
}
// ...
}
So, opening any page by an unauthenticated user results in immediate redirect to Microsoft sign-in page. And in order to get to /account/login (to have a chance to log-in using another authentication) users have to open that URL explicitly.
If I remove AzureADDefaults.AuthenticationScheme from default policy, then all unauthenticated requests will now get redirected to /account/login - exactly what I want - but naturally Azure AD authentication doesn't work anymore:
These redirects tell me that after successful authentication at Microsoft sign-in page it returns user back to /account/login, but user is still not authenticated on my website.
I can of course add [AllowAnonymous] to Index action of HomeController and return redirect to /account/login for unauthenticated users, but that obviously would only work for / route.
I have a feeling that I don't understand some things about AddAuthentication(), schemes and policies, thus apparently I did something wrong in Startup.cs. Can you please help me to understand what's wrong there? Or maybe there is some other way to achieve what I want?

Updated answer
I decided to clone the example project mentioned here in the quickstart-v2-aspnet-core-webapp documentation and see if I could reproduce your error.
After cloning the project I added two NuGet packages.
Microsoft.AspNetCore.Identity 2.2.0
Microsoft.AspNetCore.Identity.EntityFrameworkCore 2.2.0
Then added the database context that extends IdentityContext.
ApplicationDbContext.cs
In Startup.cs
Registered Identity
Registered the database context and provided connection string
In AppSettings.json
Configured TenantID and ClientID
Ran the application.
At this point, the app launches and redirects me to Account/Login, where I choose Sign in via Microsoft account.
Now, I can obviously see there is something wrong. It wouldn't authenticate the user.
Turns out:
The extension method .AddAzureAd() actually cannot be used in combination with other authentication methods. See this issue on github.
But luckily the workaround is fairly simple. Just switch out .AddAzureAd() for .AddOpenIdConnect() and change your AppSettings' AzureAd section to:
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Authority": "https://login.microsoftonline.com/{tenantID}/v2.0/",
"TenantId": "{tenantID}",
"ClientId": "{clientID}",
"CallbackPath": "/signin-oidc"
},
Now I can log in perfectly fine with AzureAD and local user accounts as well.
For your convenience, I uploaded the complete example project to my GitHub page.

Related

How do I authorise the Hangfire Dashboard via Microsoft Single Sign-On with Angular 12 and ASP.Net Core 5

My application is an Angular 12 application running on ASP.Net Core 5.
I am currently trying to lock down Hangfire so that it will only work for people with the Admin role.
It uses Microsoft Identity to log in - specifically Single Sign-on, set up in Azure.
public void ConfigureServices(IServiceCollection services)
{
...
services.AddHangfire(x =>
{
x.UseSqlServerStorage(sqlServerConnectionString);
});
...
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(Configuration);
...
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
...
app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[] {
new HangfireAuthorisationFilter()
},
AppPath = "/"
});
...
app.UseEndpoints(endpoints => {
...
});
app.UseSpa(spa=>{
...
});
}
This works in my dot net core controllers.
All I need to do to get it to work is add the Authorize attribute:
namespace MyAppName.Controllers
{
[Produces("application/json")]
[Route("api/MyRoute")]
[Authorize(Roles="Role1,Role2,Administrator")]
public class MyControllerController: MyBaseApiController
{
...
}
}
But when I want to Authorise in Hangfire, the User object is missing a whole lot of its properties.
Here is the HangfireAuthorisationFilter:
public class HangfireAuthorisationFilter : IDashboardAuthorizationFilter
{
public HangfireAuthorisationFilter()
{
}
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
// the next line always fails. The User object is set. The Identity object is set
// but there are no claims and the User.Name is null. There are also no roles set.
return httpContext.User.Identity.IsAuthenticated;
}
}
There is, however, cookie information, containing the msal cookie:
How can I pass authentication information into the Hangfire Authorize method? How can I access the role information so that I can lock it down to just the Admin role? Is there a way I can decode the msal cookie server-side?
Assuming you have an AzureAd configuration block that looks like below:
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]",
"TenantId": "[Enter 'common', or 'organizations' or the Tenant Id (Obtained from the Azure portal. Select 'Endpoints' from the 'App registrations' blade and use the GUID in any of the URLs), e.g. da41245a5-11b3-996c-00a8-4d99re19f292]",
"ClientId": "[Enter the Client Id (Application ID obtained from the Azure portal), e.g. ba74781c2-53c2-442a-97c2-3d60re42f403]"
}
I think a better approach to avoid manual validation of the token is to change your code to the following:
public void ConfigureServices(IServiceCollection services)
{
services.AddHangfire(x =>
{
x.UseSqlServerStorage(sqlServerConnectionString);
});
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(Configuration);
services.
.AddAuthentication(AzureADDefaults.AuthenticationScheme)
.AddAzureAD(options => Configuration.Bind("AzureAd", options));
services.AddAuthorization(options =>
{
options.AddPolicy("Hangfire", builder =>
{
builder
.AddAuthenticationSchemes(AzureADDefaults.AuthenticationScheme)
.RequireRole("Admin")
.RequireAuthenticatedUser();
});
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapHangfireDashboard("/hangfire", new DashboardOptions()
{
Authorization = Enumerable.Empty<IDashboardAuthorizationFilter>()
})
.RequireAuthorization("Hangfire");
});
}
To break this down, the following changes have been made:
Add authentication for AzureADDefaults.AuthenticationScheme so we can create a policy requiring the "Admin" role.
Add a policy named "Hangfire" that requires the "Admin" role against a user. See the AddAuthorization call.
Instead of calling UseHangfireDashboard we call MapHangfireDashboard inside UseEndpoints and protect the hangfire dashboard endpoint using our "Hangfire" policy through the call to RequireAuthorization("Hangfire")
Removal off the HangfireAuthorisationFilter which is not needed and instead we pass an empty collection of filters in the MapHangfireDashboard call.
The key takeaway is that we are now relying on the security provided by the middleware rather than the implementation of IDashboardAuthorizationFilter which comes with huge risk around the token being invalid and/or a mistake is made in the logic.
Ok I have figured out how to decode the msal cookie to get my list of claims and roles, and authorise successfully with Hangfire
using Hangfire.Dashboard;
using System.IdentityModel.Tokens.Jwt;
namespace MyApp.Filters
{
public class HangfireAuthorisationFilter : IDashboardAuthorizationFilter
{
public HangfireAuthorisationFilter()
{
}
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
var cookies = httpContext.Request.Cookies;
var msalIdToken = cookies["msal.{your app client id goes here}.idtoken"];
var token = new JwtSecurityTokenHandler().ReadJwtToken(msalIdToken);
foreach(var claim in token.Claims)
{
if (claim.Type=="roles" && claim.Value == "Admin")
{
return true;
}
}
return false;
}
}
}

Create Role-Based Authorization with Windows Authentication

I have an ASP.NET Core application where I want to add role-based authentication. I'm using Windows Authentication because it's an intranet app. I already have a custom database that contains the users/roles that frankly doesn't map to the fields in the IdentityFramework. I can easily get the logged-in user's name via the Context.User.Identity.Name. I then want to look up the user in the custom user/roles table in order to get the available roles for that user. Then I want to use an annotation-based authentication filter decorated at the Controller or Action method level. For example, [Authorize(roles="admin")].
I was able to get this working by turning off Windows Authentication and using Forms Authentication with Cookies. In the AccountController I ran code like this:
using(LDAPConnection connection = new LDAPConnection(loginModel.UserName,loginModel.Password))
{
List<Claim> claims = new List<Claim> {
new Claim(ClaimTypes.Name, loginModel.UserName),
new Claim(ClaimTypes.Role, "admin")
};
ClaimsIdentity userIdentity = new ClaimsIdentity(claims,"login");
ClaimsPrincipal principal = new ClaimsPrincipal(userIdentity);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(principal),
new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTime.Now.AddDays(200)
});
return Redirect("/");
}
I would then store the claims in a cookie. Then when I decorate the Controller with [Authorize(roles="admin")], I'm able to retrieve the View without issues. The authorization works. I would like to replicate this same functionality for WindowsAuthentication without logging the user in. I have tried using a ClaimsTransformer and implementing Policy-based authorization, which works. But if I decorate it with [Authorize(roles="admin")] it bombs when I navigate to the action method. Here is the ClaimsTransformer:
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var identity = (ClaimsIdentity)principal.Identity;
List<Claim> claims = new List<Claim> {
new Claim(ClaimTypes.Name, identity.Name),
new Claim(ClaimTypes.Role, "admin")
};
identity.AddClaims(claims);
return Task.FromResult(principal);
}
What piece am I missing in order to use the [Authorize(Roles="admin")] working? BTW, I'm currently using ASP.NET Core 2.2.
You could write a custom Policy Authorization handlers in which you get all User's Roles and check if they contains your desired role name.
Refer to following steps:
1.Create CheckUserRoleRequirement(accept a parameter)
public class CheckUserRoleRequirement: IAuthorizationRequirement
{
public string RoleName { get; private set; }
public CheckUserRoleRequirement(string roleName)
{
RoleName = roleName;
}
}
2.Create CheckUserRoleHandler
public class CheckUserRoleHandler : AuthorizationHandler<CheckUserRoleRequirement>
{
private readonly IServiceProvider _serviceProvider;
public CheckUserRoleHandler(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
CheckUserRoleRequirement requirement)
{
var name = context.User.Identity.Name;
using (var scope = _serviceProvider.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<YourDbContext>();
//your logic to look up the user in the custom user/roles table in order to get the available roles for that user
List<string> roles = dbContext.UserRoles.Where(...;
if (roles != null && roles.Contains(requirement.RoleName))
{
context.Succeed(requirement);
}
}
return Task.CompletedTask;
}
}
3.Register Handler in ConfigureServices
services.AddAuthorization(options =>
{
options.AddPolicy("AdminRole", policy =>
policy.Requirements.Add(new CheckUserRoleRequirement("Admin")));
});
services.AddSingleton<IAuthorizationHandler, CheckUserRoleHandler>();
4.Usage
[Authorize(Policy = "AdminRole")]
I know this is a bit of a late answer, but I've been troubleshooting the same issue today and none of the answers I've seen on similar posts have fixed my issue.
Here are the steps I took to be able to use [Authorize(Roles = "Admin")] on my controller with Windows authentication.
Double check that UseAuthentication() comes before UseAuthorization() in the Configure() method of Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication(); // <--- this needs to be before
app.UseAuthorization(); // <----this
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Hccc}/{action=Index}/");
});
}
Have a claims transformer to handle the necessary roles. For example,
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var ci = (ClaimsIdentity)principal.Identity;
var user = UserAuth.GetUserRole(ci.Name); // gets my user from db with role
// handle your roles however you need.
foreach(var role in user.Roles)
{
var roleClaim = new Claim(ci.RoleClaimType, role.RoleName);
ci.AddClaim(roleClaim);
}
return Task.FromResult(principal);
}
Set up the ConfigureServices() method in Startup.cs to handle authorization
services.AddSingleton<IClaimsTransformation, ClaimsTransformer>();
// Implement a policy called "AdminOnly" that uses "Windows" authentication
// The policy requires Role "Admin"
services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
{
policy.AddAuthenticationSchemes("Windows");
policy.RequireRole("Admin");
});
});
services.AddMvc();
services.AddControllersWithViews();
Use the [Authorize] tag to implement the policy. For my case, I wanted to block access to a controller unless the user was an "Admin".
[Authorize(Policy = "AdminOnly")]
public class UsersController : Controller
{
}

authorization failing with custom authentication scheme - asp.net-core 3.0

i'm using:
services.AddAuthentication().AddCookie("custom", options=> {
options.LoginPath = "/admin/in";
options.LogoutPath = "/admin/out";
});
in ConfigureServices to register a custom authentication scheme.
when i try to use this scheme in my controller:
[Authorize(Roles = "admin", AuthenticationSchemes = "custom")]
public ActionResult Index()
{
return View();
}
it simply redirects me to the login page even if the user is logged in and is in the correct role.
now if i change the AuthenticationScheme to be:
[Authorize(Roles = "admin", AuthenticationSchemes = "Identity.Application")]
public ActionResult Index()
{
return View();
}
everything works correctly and the user is authorized.
is there something i'm missing in the setup of the custom authentication scheme? how do i get my custom authentication scheme to work the same way Identity.Application does?
thanks
ps: i'm using asp.net core 3.0 preview 9
it appears that setting options.ForwardAuthenticate = "Identity.Application"; solves this problem:
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication().AddCookie("custom", options=> {
options.LoginPath = "/admin/in";
options.LogoutPath = "/admin/out";
options.AccessDeniedPath= "/admin/in";
options.ForwardAuthenticate = "Identity.Application";
});
}
this should really be documented

Authorization .net core 2.0

I have setup cookie authentication in my asp.net core application. I have a login page where the credentials matchup against the active directory. All this works fine.
Now next I want to implement authorization in my application. I have a table of users together with permission against them. For example permission like Reading & Write. When the user is successfully authenticated I want to check for these permissions and show them certain functionality while restricting others. For example, show certain dropdowns for write permission while hiding for reading permission.
What is the best approach to handle this in the .NET Core.
I have read about adding policy like:
services.AddAuthorization(options => {
options.AddPolicy("Read", policy =>
policy.RequireClaim("Read", "MyCLaim"));
});
Then in my controller:
[Authorize(Policy = "Read")]
public class HomeController : Controller
{
}
Where do I get the permissions for logged in user from my database and how to verify if the user has those permissions or not.
Would appreciate inputs.
Where do I get the permissions for logged in user from my database and
how to verify if the user has those permissons or not.
Right after a user is authenticated, you collect user's claims, and store them in Authentication Cookie.
For example, SignInAsync method.
public async Task SignInAsync(User user, IList<string> roleNames)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.Sid, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.UserName),
new Claim(ClaimTypes.GivenName, user.FirstName),
new Claim(ClaimTypes.Surname, user.LastName)
};
foreach (string roleName in roleNames)
{
claims.Add(new Claim(ClaimTypes.Role, roleName));
}
var identity = new ClaimsIdentity(claims, "local", "name", "role");
var principal = new ClaimsPrincipal(identity);
await _httpContextAccessor.HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme, principal);
}
FYI: It happens to be that I store them as Role claims. You do not have to follow that route, if you don't want.
You can then verify the policy inside Startup.cs.
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
...
// Set up policies from claims
// https://leastprivilege.com/2016/08/21/why-does-my-authorize-attribute-not-work/
services.AddAuthorization(options =>
{
options.AddPolicy(Constants.RoleNames.Administrator, policyBuilder =>
{
policyBuilder.RequireAuthenticatedUser()
.RequireAssertion(context => context.User.HasClaim(
ClaimTypes.Role, Constants.RoleNames.Administrator))
.Build();
});
});
...
}
}
Usage is same as what you have described.
[Authorize(Policy = "Read")]
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
}

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.