OpenIdConnect Redirect для формы POST - PullRequest
       42

OpenIdConnect Redirect для формы POST

1 голос
/ 21 октября 2019

Почему форма POST с просроченным access_token приводит к GET при использовании промежуточного программного обеспечения Microsoft.AspNetCore.Authentication.OpenIdConnect? Когда это происходит, любые данные, введенные в форму, теряются, так как они не достигают конечной точки HttpPost. Вместо этого запрос перенаправляется на тот же URI с GET, после перенаправления signin-oidc. Это ограничение, или у меня что-то настроено неправильно?

Я заметил эту проблему после сокращения AccessTokenLifetime с целью принудительного возобновления утверждений пользователя (т. Е. Если пользователь был отключен или у него былопретензии отозваны). Я воспроизводил это только тогда, когда для OpenIdConnectionOptions промежуточного программного обеспечения OpenIdConnectionOptions задано значение true options.UseTokenLifetime = true; (установка этого значения на false приводит к тому, что утверждения аутентифицированного пользователя не обновляются должным образом).

Мне удалось воссоздать и продемонстрировать это поведениес помощью примера быстрого запуска IdentityServer4 5_HybridFlowAuthenticationWithApiAccess со следующими изменениями ниже. В основном, есть авторизованная форма, которая имеет HttpGet и метод HttpPost. Если вы дольше, чем AccessTokenLifetime (в этом примере настроено только 30 секунд), вместо метода HttpPost вызывается метод HttpGet.

Изменения MvcClient / Startup.cs

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");
        });
}

Изменения в списке клиентов в IdentityServer / Config.cs

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,
}

Добавлено в MvcClient / Controllers / HomeController

[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);
}

1 Ответ

1 голос
/ 25 октября 2019

После дальнейших исследований и расследований я пришел к выводу, что заполнение формы POST, перенаправленной поставщику OIDC, не поддерживается сразу после установки (по крайней мере, для Identity Server, но я подозреваю, что это верно и для других идентификаторов). подключи провайдеров тоже). Вот единственное упоминание, которое я могу найти об этом: Отправка пользовательских параметров на страницу входа

Мне удалось найти обходной путь для проблемы, который я изложил ниже и которыйнадеюсь, полезно для других. Ключевыми компонентами являются следующие события промежуточного программного обеспечения OpenIdConnect и Cookie:

  • OpenIdConnectEvents.OnRedirectToIdentityProvider - сохранение запросов Post для последующего поиска
  • CookieAuthenticationEvents.OnValidatePrincipal - проверка сохраненных запросов Post и обновление текущихзапрос с сохраненным состоянием

Промежуточное программное обеспечение OpenIdConnect предоставляет событие OnRedirectToIdentityProvider, которое дает нам возможность:

  • определить, является ли это публикацией формы длямаркер доступа с истекшим сроком действия
  • изменить RedirectContext, добавив в него пользовательский идентификатор запроса со словарем AuthenticationProperties Items
  • Сопоставить текущий HttpRequest с объектом HttpRequestLite, который можно сохранить в хранилище кэша,Я рекомендую использовать устаревший распределенный кэш для сред с балансировкой нагрузки. Для простоты я использую статический словарь
    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;
        },
    };

Промежуточное программное обеспечение Cookie предоставляет событие OnValidatePrincipal, которое дает нам возможность:

  • проверить CookieValidatePrincipalContext для AuthenticationProperties Элементы для пользовательских элементов словаря. Мы проверяем его на наличие идентификатора нашего сохраненного / кэшированного запроса
    • . Важно, чтобы мы удалили элемент после того, как прочитали его, чтобы последующие запросы не воспроизводили неверную отправку формы, если для ShouldRenew установлено значение true, любые изменения сохраняются. при последующих запросах
  • проверьте наш внешний кэш на предмет элементов, соответствующих нашему ключу, я рекомендую использовать распределенный кэш с истекающим сроком действия для сред с балансировкой нагрузки. Для простоты я использую статический словарь
  • прочитайте наш пользовательский HttpRequestLite объект и переопределите объект Request в CookieValidatePrincipalContext объекте

    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;
        },
    };

Нам нужен классотобразить HttpRequest в / из для целей сериализации. Это читает HttpRequest и его тело без изменения содержимого, он оставляет HttpRequest без изменений для дополнительного промежуточного программного обеспечения, которое может попытаться прочитать его после того, как мы это сделаем (это важно при попытке прочитать поток Body, который по умолчанию может быть прочитан только один раз).


    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; }
    }

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...