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.

2 Answers 2

2

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();
});
Sign up to request clarification or add additional context in comments.

9 Comments

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).
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): ...
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 github.com/dotnet/aspnetcore/blob/… - 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
@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.
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); };
|
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.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.