In asp.net core, why is await context.ChallengeAsync() not working as expected?

﹥>﹥吖頭↗ 提交于 2019-12-25 01:46:53

问题


I have two questions, both of which refer to the code below:

  1. Why is authenticateResult.Succeeded false after I call authenticateResult = await context.AuthenticateAsync();?

  2. Why do I need to call "return" from my custom middleware InvokeAsync method for this to work properly?

I have an asp.net core application using OpenIdConnect. The application has two controller actions; both of them have the [Authorize] attribute, so when the application starts the user is automatically put through the OpenIdConnect process. This works fine.

Here is how I configure my OpenIdConnect middleware, I happen to be using PingOne:

            services.AddAuthentication(authenticationOptions =>
            {
                authenticationOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                authenticationOptions.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
            })
            .AddCookie()
            .AddOpenIdConnect(openIdConnectOptions =>
            {
                openIdConnectOptions.Authority = Configuration["PingOne:Authority"];
                openIdConnectOptions.CallbackPath = Configuration["PingOne:CallbackPath"];
                openIdConnectOptions.ClientId = Configuration["PingOne:ClientId"];
                openIdConnectOptions.ClientSecret = Configuration["PingOne:ClientSecret"];

                openIdConnectOptions.ResponseType = Configuration["PingOne:ResponseType"];
                openIdConnectOptions.Scope.Clear();
                foreach (var scope in scopes.GetChildren())
                {
                    openIdConnectOptions.Scope.Add(scope.Value);
                }
            });

Immediately after a user authenticates I redirect the user to another website (which uses the same OpenIdConnect authentication). On "OtherWebsite" the user selects various options and then gets redirected back to the "OriginalWebsite" to a special path called "ReturningFromOtherWebsite". On return to OriginalWebSite I read the querystring, load some claims into the user's principal identity based on the querystring, and set a Session variable so that I know I've visited OtherWebSite once.

I do not actually have a Controller method called "ReturningFromOtherWebsite" in OriginalWebSite, so I need to look for that path in my middleware and intercept handling of it.

I decided to wrap this functionality in custom middleware I call "AfterAuthenticationMiddleware", which looks like this. My questions are marked by the comments that start with "//QUESTION:..."

public class AfterAuthenticationMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IConfiguration Configuration;
    private IMembershipRepository MembershipRepository;

    public AfterAuthenticationMiddleware(RequestDelegate next, 
        IConfiguration configuration)
    {
        _next = next;
        Configuration = configuration;
    }

    private void SignInWithSelectedIdentity(Guid userId, 
        ClaimsIdentity claimsIdentity,
        AuthenticateResult authenticateResult,
        HttpContext context)
    {
        string applicationName = Configuration["ApplicationName"];

        List<string> roles = MembershipRepository.GetRoleNamesForUser(userId, applicationName);

        foreach (var role in roles)
        {
            claimsIdentity.AddClaim(new Claim(ClaimTypes.Role, role));
        }

        //add the claim to the authentication cookie
        context.SignInAsync(authenticateResult.Principal, authenticateResult.Properties);
    }


    public async Task InvokeAsync(HttpContext context, 
        IMembershipRepository membershipRepository)
    {
        MembershipRepository = membershipRepository;

        bool isIdentitySelected = context.Session.GetBoolean("IsIdentitySelected").GetValueOrDefault();

        if (isIdentitySelected)
        {
            //I know from existence of Session variable that there is no work to do here.
            await _next(context);
            return;
        }

        var authenticateResult = await context.AuthenticateAsync();
        ClaimsIdentity claimsIdentity = null;

        //the Controller action ReturningFromOtherWebSite does not actually exist.
        if (context.Request.Path.ToString().Contains("ReturningFromOtherWebSite"))
        {
            if (!authenticateResult.Succeeded)
            {
                //this next line triggers the OpenIdConnect process
                await context.ChallengeAsync();

                //QUESTION: If I re-fetch the authenticateResult here, why is IsSucceeded false, for example:
                //var authenticateResult = await context.AuthenticateAsync();

                //QUESTION: why is the next line needed for this to work
                return;


            }

            claimsIdentity = (ClaimsIdentity)authenticateResult.Principal.Identity;

            //set the Session variable so that on future requests we can bail out of this method quickly.
            context.Session.SetBoolean(Constants.IsIdentitySelected, true);
            var request = context.Request;

            //load some claims based on what the user selected in "OtherWebSite"
            string selectedIdentity = request.Query["selectedIdentity"];

            if (!Guid.TryParse(selectedIdentity, out Guid userId))
            {
                throw new ApplicationException(
                    $"Unable to parse Guid from 'selectedIdentity':{selectedIdentity} ");
            }

            SignInWithSelectedIdentity(userId, claimsIdentity, authenticateResult, context);

            //redirect user to the page that the user originally requested
            string returnUrl = request.Query["returnUrl"];
            if (string.IsNullOrEmpty(returnUrl))
                throw new ApplicationException(
                    $"Request is ReturnFromIdentityManagement but missing required parameter 'returnUrl' in querystring:{context.Request.QueryString} ");

            string path = $"{request.Scheme}://{request.Host}{returnUrl}";

            Log.Logger.Verbose($"AfterAuthentication InvokeAsync Redirect to {path}");
            context.Response.Redirect(path);
            //I understand why I call "return" here; I just want to send the user on to the page he/she originally requested without any more middleware being invoked
            return;
        }

        if (!authenticateResult.Succeeded)
        {
            //if the user has not gone through OIDC there is nothing to do here
            await _next(context);
            return;
        }

        //if get here it means user is authenticated but has not yet selected an identity on OtherWebSite
        claimsIdentity = (ClaimsIdentity)authenticateResult.Principal.Identity;

        Log.Logger.Verbose($"AfterAuthentication InvokeAsync check if redirect needed.");
        var emailClaim = claimsIdentity.Claims.FirstOrDefault(o => o.Type == ClaimTypes.Email);
        if(emailClaim == null)
            throw new ApplicationException($"User {authenticateResult.Principal.Identity.Name} lacks an Email claim");

        string emailAddress = emailClaim.Value;
        if(string.IsNullOrWhiteSpace(emailAddress))
            throw new ApplicationException("Email claim value is null or whitespace.");

        string applicationName = Configuration["ApplicationName"];
        if(string.IsNullOrEmpty(applicationName))
            throw new ApplicationException("ApplicationName missing from appsettings.json.");

        //if there is just one userid associated with the email address, load the claims.  if there is
        //more than one the user must redirect to OtherWebSite and select it
        List<Guid?> userIds =
            MembershipRepository.IsOtherWebsiteRedirectNeeded(emailAddress, applicationName);

        if (userIds == null
            || userIds[0] == null
            || userIds.Count > 1)
        {
            //include the path the user was originally seeking, we will redirect to this path on return
            //cannot store in session (we lose session on the redirect to other web site)
            string queryString =
                $"emailAddress={emailAddress}&applicationName={applicationName}&returnUrl={context.Request.Path}";

            context.Response.Redirect($"https://localhost:44301/Home/AuthenticatedUser?{queryString}");
        }
        else
        {
            SignInWithSelectedIdentity(userIds[0].Value, claimsIdentity, authenticateResult, context);
        }

        await _next(context);
    }
}

And then I add the middlewares in the Configure method in the usual way:

app.UseAuthentication();
app.UseAfterAuthentication();
app.UseAuthorization();

I added the "return" call out of desperation and was shocked to discover that it fixed the problem, but I won't feel comfortable until I know why it fixed the problem.


回答1:


I'm going to hazard a guess as to what is happening.

I've hooked up a listener to the OpenIdConnect library at the end of the Configure() method, like so:

IdentityModelEventSource.Logger.LogLevel = EventLevel.Verbose;
IdentityModelEventSource.ShowPII = true;
var listener = new MyEventListener();
listener.EnableEvents(IdentityModelEventSource.Logger, EventLevel.Verbose);
listener.EventWritten += Listener_EventWritten;

and then inside the Listener_EventWritten event I'm logging to a database.

private void Listener_EventWritten(object sender, EventWrittenEventArgs e)
    {
        foreach (object payload in e.Payload)
        {
            Log.Logger.Information($"[{e.EventName}] {e.Message} | {payload}");
        }
    }

I've also added verbose logging throughout the application, to get a sense of what is happening. Unfortunately there does not seem to be any way to attach listeners to the Authentication or Authorization middlewares.

Here is what I believe is happening. Each asp.net core middleware fires sequentially--in forward order during the Request, then in backwards order during the Response. When I hit the bit of code in my custom middleware that confused me:

if (context.Request.Path.ToString().Contains("ReturningFromOtherWebSite"))
    {
        if (!authenticateResult.Succeeded)
        {
            //this next line triggers the OpenIdConnect process
            await context.ChallengeAsync();

            //QUESTION: If I re-fetch the authenticateResult here, why is IsSucceeded false, for example:
            //var authenticateResult = await context.AuthenticateAsync();

            //QUESTION: why is the next line needed for this to work
            return;
        } 

the call to "await context.ChallengeAsync();" fires the Authentication middleware; I can see from my logging that both the Oidc and Cookie authentication fire at this point. A "return" is needed after this call because I don't want the thread of execution to continue in my custom middleware; instead I want to let the call to "await context.ChallengeAsync();" complete its work and invoke my custom middleware again.

I can see from my logging that my custom middleware is indeed invoked again, and this time the authenticateResult.Succeeded is true.

The call to var "authenticateResult = await context.AuthenticateAsync();" yields a "Succeeded" of false because my custom middleware does not "know" at this point that the user has authenticated. The only way my custom middleware will "know" this is when the Authentication middleware calls it with "await(next)". That means I need to return and simply wait for that invocation.

Again, this is my guess, if anyone knows for certain I'd appreciate a better explanation. I've tried looking at the Oidc source code but I admit I find it bewildering, as I'm new to Core and have not yet fully grasped the whole async business yet.



来源:https://stackoverflow.com/questions/59379370/in-asp-net-core-why-is-await-context-challengeasync-not-working-as-expected

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!