0

My project has two controllers to support users from different roles - Members and Consultants. On sign-in I set the "Role" ClaimType for each.

There is a different sign-in page for members and consultants and after sign-in both the MemberController and ConsultantController redirect to a "Desktop" action.

CONSULTANTCONTROLLER.CS

    [HttpPost()]
    [AllowAnonymous]
    [ValidateAntiForgeryToken]
    public async Task<ActionResult> SignIn(SignIn sin)
    {
        try
        {
            // check authorisation
            if (ModelState.IsValid)
            {
                sin = await RepoSamadhi.ShopSignIn(sin);
                if (sin.ShopID == 0 || sin.IsValidationFail || string.IsNullOrEmpty(sin.ShopToken))
                {
                    is_err = true;
                    _logger.LogInformation("Consultant SignIn Invalid Credentials", sin.EmailAddress);                        
                    ModelState.AddModelError("Consultant", "Account not found. Check your credentials.");
                }
            }                
            else
            {
                sin.IsSignInFailed = true;
                return View("SignIn", sin);
            }

            // create claims
            var claims = new List<Claim>
        {
            new Claim(ClaimTypes.Sid, sin.ShopToken),
            new Claim(ClaimTypes.NameIdentifier, sin.ShopID.ToString()),
            new Claim(ClaimTypes.Email, sin.EmailAddress.ToLower()),
            new Claim(ClaimTypes.Role, "Consultant")
        };

            // create identity
            var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); // cookie or local            

            // create principal
            ClaimsPrincipal principal = new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme));

            // create auth properties
            var authProperties = new AuthenticationProperties
            {
                IsPersistent = sin.RememberMe;
            };

            // sign-in
            await HttpContext.SignInAsync(scheme: CookieAuthenticationDefaults.AuthenticationScheme, principal: principal, properties: authProperties);
        }
        catch (Exception ex)
        {
            gFunc.ProcessError(ex);
        }
        return RedirectToAction("Desktop", new { date = DateTime.Today.ToString("d MMM yyyy"), timer = false });
    }

STARTUP.CS

    public void ConfigureServices(IServiceCollection services)
    {
        try
        {
            services.AddRazorPages()
                .AddRazorRuntimeCompilation();

            services.AddControllersWithViews();

            services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
                .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
                {
                    options.ExpireTimeSpan = new TimeSpan(30, 0, 0, 0);
                    options.LoginPath = new PathString("/Home/Index/");
                    options.AccessDeniedPath = new PathString("/Home/Index/");
                    options.LogoutPath = new PathString("/Home/Index/");
                    options.Validate();
                });

            services.Configure<Microsoft.AspNetCore.Identity.IdentityOptions>(options =>
            {
                options.Password.RequireDigit = true;
                options.Password.RequireLowercase = true;
                options.Password.RequireNonAlphanumeric = true;
                options.Password.RequireUppercase = true;
                options.Password.RequiredLength = 8;
                options.Password.RequiredUniqueChars = 1;
                options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
                options.Lockout.MaxFailedAccessAttempts = 5;
                options.Lockout.AllowedForNewUsers = true;
                options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
                options.User.RequireUniqueEmail = false;
            });

            // add detection services container and device resolver service
            services.AddDetectionCore()
                .AddDevice();

            services.AddMvc();
            services.AddAntiforgery();
            services.Configure<MvcOptions>(options =>
            {
                options.Filters.Add(new RequireHttpsAttribute());
            });
        }
        catch (Exception ex)
        {
            gFunc.ProcessError(ex);
        }
    }

QUESTION

How can I configure the Authentication service to redirect the user to the correct SignIn page when they attempt to access an Authorize resource) but are not signed in (ie. no valid authentication cookie)? At the moment I have just one "AccessDeniedPath" and it takes the user to the home page.

Ross Kelly
  • 477
  • 1
  • 6
  • 23

2 Answers2

1

I tried King King's approach by customizing the CookieAuthenticationHandler to override HandleForbiddenAsync, but the code never executes.

This is because a user who has not signed in yet is "unauthorized". If they attempt to access an [Authorize] resource, the user is directed to LoginPath, not AccessDeniedPath. This corresponds to a 401 in terms of HTTP requests.

A user is "forbidden" if they have already signed in, but the identity they are using does not have permission to view the requested resource, which corresponds to a 403 in HTTP.

In MS docs: "AccessDeniedPath Gets or sets the optional path the user agent is redirected to if the user doesn't approve the authorization demand requested by the remote server. This property is not set by default. In this case, an exception is thrown if an access_denied response is returned by the remote authorization server."

So after signing in and subsequently requesting a protected resource without the required role (i.e. action decorated with [Authorize(Roles = "MyRole")], should be redirected to the configured AccessDeniedPath. In this case I should be able to use King King's approach.

SOLUTION

In the end I've simply added a delegate to the CookieAuthenticationOptions event (OnRedirectToLogin).

I've updated the below code to incorporate feedback/comments from KingKing. This includes using StartsWithSegments instead of just Path.ToString().Contains.

Also as per KK's suggestion, I capture the default callback and then use it in the return.

services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, o =>
{
     o.ExpireTimeSpan = new TimeSpan(90, 0, 0, 0);
     o.AccessDeniedPath = new PathString("/Samadhi/SignIn/");
     o.LoginPath = new PathString("/Samadhi/SignIn/");
     o.LogoutPath = new PathString("/Samadhi/SignOut/");
     var defaultCallback = o.Events.OnRedirectToLogin;
     o.Events.OnRedirectToLogin = context =>
     {
          if (context.Request.Path.StartsWithSegments(new PathString("/member"), StringComparison.OrdinalIgnoreCase))
          {
               context.RedirectUri = "/Member/SignIn/";
               context.Response.Redirect(context.RedirectUri);
          }
          else if (context.Request.Path.StartsWithSegments(new PathString("/consultant"), StringComparison.OrdinalIgnoreCase))
          {
               context.RedirectUri = "/Consultant/SignIn/";
               context.Response.Redirect(context.RedirectUri);
          }
          return defaultCallback(context);
    };
    o.Validate();
});
Ross Kelly
  • 477
  • 1
  • 6
  • 23
  • the redirect uri can be a relative path (rooted from the host address) but must start with `/`, so in your case you should use `/member/SignIn` ... Also your logic of `Request.Path.Contains` is not very safe, it may pick the wrong path to handle. So you need to match the path fairly exactly to handle the correct paths you want. You may need to use `Regex` for that purpose if you don't want to manually find the match (with a tradeoff between being more performant and less convenient). – King King Mar 24 '21 at 17:50
  • I deleted my answer because actually the `Options` is shared, so modifying it should be forbidden. Below is my original comment about your code (moved from my answer): ... – King King Mar 25 '21 at 05:38
  • as in your re-implementation of the callback, you lack the case of supporting redirecting for ajax requests, as handled by the default implementation here https://github.com/dotnet/aspnetcore/blob/c925f99cddac0df90ed0bc4a07ecda6b054a0b02/src/Security/Authentication/Cookies/src/CookieAuthenticationEvents.cs#L39 - that's why we should get the default & call it after changing the context.Uri in our callback. In the future, the default may have other additional logic that we will not be aware but still our code will not have to be updated accordingly – King King Mar 25 '21 at 05:40
  • @KingKing strange, when I tried to implement the static IsAjaxRequest method I get a compile error saying HeaderNames does not contain a definition for "XRequestedWith". In my project Microsoft.Net.Http.Headers shows assembly version 3.1.0.0. – Ross Kelly Mar 25 '21 at 07:08
  • the `XRequestedWith` is new in `.NET 5` so of course you cannot use it in `.net core 3.1`. You can use a string of `X-Requested-With` instead. However as I said, you should not copy the code there to your implementation. Just capture the default callback first, later call that callback inside your implementation. e.g: `var defaultCallback = o.Events.OnRedirectToLogin; o.Events.OnRedirectToLogin = context => { .... return defaultCallback(context); };` – King King Mar 25 '21 at 07:28
  • @KingKing sorry my misunderstanding. yes XRequestedWith is just a literal string. I understand what you mean now & I've updated my answer with the code changes. – Ross Kelly Mar 25 '21 at 07:57
  • yes it looks right now, although the code you update the `RedirectUri` is still not very good (I already commented about that at first). You can accept your own answer now to make it stand-out and may be helpful for other readers in the future. – King King Mar 25 '21 at 08:00
  • @KingKing I just thought of something strange about Cookie Authentication. Maybe I've got this wrong but what happens in this scenario: User with ID 123 signs in (ie. db checks user exists & credentials are valid) and subsequently gets a cookie. before the cookie expires user 123 leaves the company and his account is archived. now he is no longer a valid user but he still has a valid cookie! – Ross Kelly Mar 25 '21 at 08:09
  • you need to handle that scenario manually, at least there is a basic example here on the documentation page: https://learn.microsoft.com/en-us/aspnet/core/security/authentication/cookie?view=aspnetcore-5.0#react-to-back-end-changes – King King Mar 25 '21 at 08:13
0

In my opinion, the main issue is if the user doesn't contain the token, how you know the current login user?

In my opinion, I suggest you could use a main sign-in page in at first. Then if user has typed in its username, you could use js like ajax to check the username or email in the server.

If the user is Members , then you could write logic in the ajax success method to redirect the user to the Members login page.

Brando Zhang
  • 22,586
  • 6
  • 37
  • 65