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设置为trueoptions.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; }
}