Asp.net core 表单POST上的OpenIdConnect重定向

Asp.net core 表单POST上的OpenIdConnect重定向,asp.net-core,identityserver4,openid-connect,Asp.net Core,Identityserver4,Openid Connect,使用Microsoft.AspNetCore.Authentication.OpenIdConnect中间件时,为什么带有过期访问令牌的表单POST会导致GET?当这种情况发生时,任何输入表单的数据都会丢失,因为它不会到达HttpPost端点。相反,在signin oidc重定向之后,使用GET将请求重定向到同一URI。这是一个限制,还是我的配置不正确 在缩短AccessTokenLifetime后,我注意到了这个问题,目的是强制更频繁地更新用户的声明(即,如果用户被禁用或声明被撤销)。只有当O

使用
Microsoft.AspNetCore.Authentication.OpenIdConnect
中间件时,为什么带有过期访问令牌的表单POST会导致GET?当这种情况发生时,任何输入表单的数据都会丢失,因为它不会到达HttpPost端点。相反,在signin oidc重定向之后,使用GET将请求重定向到同一URI。这是一个限制,还是我的配置不正确

在缩短AccessTokenLifetime后,我注意到了这个问题,目的是强制更频繁地更新用户的声明(即,如果用户被禁用或声明被撤销)。只有当OpenIdConnect中间件的OpenIdConnectionOptions设置为true
options.UseTokenLifetime=true时,我才重现了这一点(将此设置为false将导致已验证用户的声明未按预期更新)

我能够使用IdentityServer4示例快速启动重新创建和演示此行为,并进行了以下更改。基本上,有一个具有HttpGet和HttpPost方法的授权表单。如果您等待的时间超过AccessTokenLifetime(在本例中配置为仅30秒)来提交表单,则会调用HttpGet方法而不是HttpPost方法

修改

对中客户端列表的修改

添加到

[授权]
[HttpGet]
[路由(“主/测试”,Name=“TestRouteGet”)]
公共异步任务测试()
{
TestViewModel viewModel=新的TestViewModel
{
Message=“GET at”+日期时间。现在,
TestData=DateTime.Now.ToString(),
AccessToken=wait this.HttpContext.GetTokenAsync(“访问令牌”),
RefreshToken=等待这个.HttpContext.GetTokenAsync(“刷新令牌”),
};
返回视图(“测试”,视图模型);
}
[授权]
[HttpPost]
[路由(“主/测试”,Name=“TestRoutePost”)]
公共异步任务测试(TestViewModel viewModel)
{
viewModel.Message=“POST at”+DateTime.Now;
viewModel.AccessToken=等待这个.HttpContext.GetTokenAsync(“访问令牌”);
viewModel.refreshttoken=等待这个.HttpContext.GetTokenAsync(“刷新令牌”);
返回视图(“测试”,视图模型);
}

经过进一步的研究和调查,我得出结论,不支持立即完成重定向到OIDC提供商的表单发布(至少对于Identity Server是这样,但我怀疑对于其他Identity connect提供商也是这样)。以下是我能找到的关于这一点的唯一提及:

我为这个问题找到了一个解决方法,我在下面概述了这个方法,希望对其他人有用。关键组件包括以下OpenIdConnect和Cookie中间件事件:

  • OpenIdConnectEvents.OnRedirectToIdentityProvider-保存Post请求以供以后检索
  • CookieAuthenticationEvents.OnValidatePrincipal-检查保存的Post请求,并使用保存的状态更新当前请求

OpenIdConnect中间件公开了
OnRedirectToIdentityProvider
事件,这使我们有机会:

  • 确定这是否是过期访问令牌的表单post
  • 修改
    RedirectContext
    以包含带有
    AuthenticationProperties
    项目字典的自定义请求id
  • 将当前HttpRequest映射到可以持久化到缓存存储的HttpRequestLite对象,我建议在负载平衡环境中使用过期的分布式缓存。为了简单起见,我在这里使用静态字典
Cookie中间件公开了
OnValidatePrincipal
事件,这使我们有机会:

  • 检查自定义词典项的
    CookieValidatePrincipalContext
    中的
    AuthenticationProperties
    项。我们检查它是否有已保存/缓存请求的ID
    • 重要的是,我们在阅读后删除该项,以便后续请求不会重播错误的表单提交,将
      ShouldRenew
      设置为true将在后续请求中保留任何更改
  • 检查外部缓存中是否有与密钥匹配的项,我建议在负载平衡环境中使用过期的分布式缓存。为了简单起见,我在这里使用静态字典
  • 阅读我们的自定义
    HttpRequestLite
    对象,并覆盖
    CookieValidatePrincipalContext
    对象中的请求对象
我们需要一个类来将HttpRequest映射到/从中进行序列化。这将在不修改内容的情况下读取HttpRequest及其主体,它使HttpRequest保持不变,以便其他中间件可以在我们完成后尝试读取它(这在尝试读取主体流时非常重要,默认情况下,主体流只能读取一次)


使用System.Collections.Generic;
使用System.IO;
使用系统文本;
使用System.Threading.Tasks;
使用Microsoft.AspNetCore.Http;
使用Microsoft.AspNetCore.Http.Internal;
使用Microsoft.Extensions.Primitives;
公共类HttpRequestLite
{
公共静态异步任务BuildHttpRequestLite(HttpRequest请求)
{
HttpRequestLite requestLite=新的HttpRequestLite();
尝试
{
request.EnableRewind();
使用(var reader=newstreamreader(request.Body))
{
string body=wait reader.ReadToEndAsync();
request.Body.Seek(0,SeekOrigin.Begin);
requestLite.Body=Encoding.ASCII.GetBytes(Body);
}
//requestLite.Form=request.Form;
}
抓住
{
}
requestLite.Cookies=request.Cookies;
requestLite.ContentLength=request.ContentLength;
requestLite.ContentType=request.ContentType;
foreach(request.header中的var头
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();

    JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

    services.AddAuthentication(options =>
        {
            options.DefaultScheme = "Cookies";
            options.DefaultChallengeScheme = "oidc";
        })
        .AddCookie("Cookies", options =>
        {
            // the following was added
            options.SlidingExpiration = false;
        })
        .AddOpenIdConnect("oidc", options =>
        {
            options.SignInScheme = "Cookies";

            options.Authority = "http://localhost:5000";
            options.RequireHttpsMetadata = false;

            options.ClientId = "mvc";
            options.ClientSecret = "secret";
            options.ResponseType = "code id_token";

            options.SaveTokens = true;
            options.GetClaimsFromUserInfoEndpoint = true;

            options.Scope.Add("openid");
            options.Scope.Add("api1");

            options.ClaimActions.MapJsonKey("website", "website");

            // the following were changed
            options.UseTokenLifetime = true;
            options.Scope.Add("offline_access");
        });
}
new Client
{
    ClientId = "mvc",
    ClientName = "MVC Client",
    AllowedGrantTypes = GrantTypes.Hybrid,

    ClientSecrets =
    {
        new Secret("secret".Sha256())
    },

    RedirectUris           = { "http://localhost:5002/signin-oidc" },
    PostLogoutRedirectUris = { "http://localhost:5002/signout-callback-oidc" },

    AllowedScopes =
    {
        IdentityServerConstants.StandardScopes.OpenId,
        IdentityServerConstants.StandardScopes.Profile,
        "api1",
        IdentityServerConstants.StandardScopes.OfflineAccess,
    },

    AllowOfflineAccess = true,

    // the following properties were configured:
    AbsoluteRefreshTokenLifetime = 14*60*60,
    AccessTokenLifetime = 30,
    IdentityTokenLifetime = 15,
    AuthorizationCodeLifetime = 15,
    SlidingRefreshTokenLifetime = 60,
    RefreshTokenUsage = TokenUsage.OneTimeOnly,
    UpdateAccessTokenClaimsOnRefresh = true,                    
    RequireConsent = false,
}
[Authorize]
[HttpGet]
[Route("home/test", Name = "TestRouteGet")]
public async Task<IActionResult> Test()
{
    TestViewModel viewModel = new TestViewModel
    {
        Message = "GET at " + DateTime.Now,
        TestData = DateTime.Now.ToString(),
        AccessToken = await this.HttpContext.GetTokenAsync("access_token"),
        RefreshToken = await this.HttpContext.GetTokenAsync("refresh_token"),
    };

    return View("Test", viewModel);
}
[Authorize]
[HttpPost]
[Route("home/test", Name = "TestRoutePost")]
public async Task<IActionResult> Test(TestViewModel viewModel)
{
    viewModel.Message = "POST at " + DateTime.Now;
    viewModel.AccessToken = await this.HttpContext.GetTokenAsync("access_token");
    viewModel.RefreshToken = await this.HttpContext.GetTokenAsync("refresh_token");

    return View("Test", viewModel);
}
    new OpenIdConnectEvents
    {
        OnRedirectToIdentityProvider = async (context) =>
        {
            if (context.HttpContext.Request.Method == HttpMethods.Post && context.Properties.ExpiresUtc == null)
            {
                string requestId = Guid.NewGuid().ToString();

                context.Properties.Items["OidcPostRedirectRequestId"] = requestId;

                HttpRequest requestToSave = context.HttpContext.Request;

                // EXAMPLE - saving this to memory which would work on a non-loadbalanced or stateful environment. Recommend persisting to external store such as Redis.
                postedRequests[requestId] = await HttpRequestLite.BuildHttpRequestLite(requestToSave);
            }

            return;
        },
    };


    new CookieAuthenticationEvents
    {
        OnValidatePrincipal = (context) =>
        {
            if (context.Properties.Items.ContainsKey("OidcPostRedirectRequestId"))
            {
                string requestId = context.Properties.Items["OidcPostRedirectRequestId"];
                context.Properties.Items.Remove("OidcPostRedirectRequestId");

                context.ShouldRenew = true;

                if (postedRequests.ContainsKey(requestId))
                {
                    HttpRequestLite requestLite = postedRequests[requestId];
                    postedRequests.Remove(requestId);

                    if (requestLite.Body?.Any() == true)
                    {
                        context.Request.Body = new MemoryStream(requestLite.Body);
                    }
                    context.Request.ContentLength = requestLite.ContentLength;
                    context.Request.ContentLength = requestLite.ContentLength;
                    context.Request.ContentType = requestLite.ContentType;
                    context.Request.Method = requestLite.Method;
                    context.Request.Headers.Clear();
                    foreach (var header in requestLite.Headers)
                    {
                        context.Request.Headers.Add(header);
                    }
                }

            }
            return Task.CompletedTask;
        },
    };


    using System.Collections.Generic;
    using System.IO;
    using System.Text;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Http.Internal;
    using Microsoft.Extensions.Primitives;

    public class HttpRequestLite
    {
        public static async Task<HttpRequestLite> BuildHttpRequestLite(HttpRequest request)
        {
            HttpRequestLite requestLite = new HttpRequestLite();

            try
            {
                request.EnableRewind();
                using (var reader = new StreamReader(request.Body))
                {
                    string body = await reader.ReadToEndAsync();
                    request.Body.Seek(0, SeekOrigin.Begin);

                    requestLite.Body = Encoding.ASCII.GetBytes(body);
                }
                //requestLite.Form = request.Form;
            }
            catch
            {

            }

            requestLite.Cookies = request.Cookies;
            requestLite.ContentLength = request.ContentLength;
            requestLite.ContentType = request.ContentType;
            foreach (var header in request.Headers)
            {
                requestLite.Headers.Add(header);
            }
            requestLite.Host = request.Host;
            requestLite.IsHttps = request.IsHttps;
            requestLite.Method = request.Method;
            requestLite.Path = request.Path;
            requestLite.PathBase = request.PathBase;
            requestLite.Query = request.Query;
            requestLite.QueryString = request.QueryString;
            requestLite.Scheme = request.Scheme;

            return requestLite;

        }

        public QueryString QueryString { get; set; }

        public byte[] Body { get; set; }

        public string ContentType { get; set; }

        public long? ContentLength { get; set; }

        public IRequestCookieCollection Cookies { get; set; }

        public IHeaderDictionary Headers { get; } = new HeaderDictionary();

        public IQueryCollection Query { get; set; }

        public IFormCollection Form { get; set; }

        public PathString Path { get; set; }

        public PathString PathBase { get; set; }

        public HostString Host { get; set; }

        public bool IsHttps { get; set; }

        public string Scheme { get; set; }

        public string Method { get; set; }
    }