3

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);
    }

}
OjM
  • 735
  • 2
  • 10
  • 21

3 Answers3

2

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);
}
OjM
  • 735
  • 2
  • 10
  • 21
1

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).

Ivar
  • 4,655
  • 12
  • 45
  • 50
Nan Yu
  • 21,285
  • 5
  • 39
  • 110
  • I like this approach for its simplicity but what if a user loads a particular page (ie Home/Contact). How can I ensure the Account/CheckSignIn action is called first if they load the Home/Contact page directly? – OjM Aug 14 '17 at 13:30
  • Did you use [Authorize] attribute to protect your controller or action ? If you use that , use should be redirected to azure ad login page , after authenticated, he will be redirected to Account/CheckSignIn . – Nan Yu Aug 15 '17 at 01:48
  • Yes I am using [Authorize] attribute. The problem is that if I have the default route setup as you have shown then this approach only works if I load the default site (ie mysite.com). However if I enter url mysite.com/Home/About then the default route is bypassed and the Account/CheckSignIn is never called. Is there any way around this? – OjM Aug 15 '17 at 09:49
0

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.

Marcel W
  • 2,482
  • 25
  • 42