Обработка просроченных токенов обновления в ASP.NET Core

См. ниже код, который решил эту проблему

Я пытаюсь найти лучший и наиболее эффективный способ справиться с токеном обновления, срок действия которого истек в ASP.NET Core 2.1.

Позвольте мне объяснить еще немного.

Я использую OAUTH2 и OIDC для запроса потоков предоставления кода авторизации (или гибридного потока с OIDC). Этот тип потока / гранта дает мне доступ к AccessToken и RefreshToken (также к коду авторизации, но это не для этого вопроса).

Маркер доступа и маркер обновления хранятся в ядре ASP.NET и могут быть получены с помощью HttpContext.GetTokenAsync("access_token"); и HttpContext.GetTokenAsync("refresh_token"); соответственно.

Я могу обновить access_token без проблем. Проблема возникает, когда refresh_token истек, аннулирован или недействителен каким-либо образом.

Правильный поток будет заключаться в том, чтобы пользователь вошел в систему и снова вернулся через весь поток аутентификации. Затем приложению возвращается новый набор токенов.

Мой вопрос в том, как я могу добиться этого наилучшим и наиболее правильным способом. Я решил написать специальное промежуточное ПО, которое пытается обновить access_token, если срок его действия истек. Затем промежуточное программное обеспечение устанавливает новый токен в AuthenticationProperties для HttpContext, чтобы его можно было использовать в любых последующих вызовах по конвейеру.

Если по какой-либо причине обновить токен не удается, мне нужно снова вызвать ChallengeAsync. Я вызываю ChallengeAsync из промежуточного программного обеспечения.

Здесь я столкнулся с интересным поведением. В большинстве случаев это работает, однако иногда я получаю 500 ошибок без полезной информации о том, что именно не удается. Похоже, что у промежуточного программного обеспечения возникают проблемы с попыткой вызвать ChallengeAsync из промежуточного программного обеспечения, и, возможно, другое промежуточное программное обеспечение также пытается получить доступ к контексту.

Я не совсем понимаю, что происходит. Я не совсем уверен, правильное ли это место для размещения этой логики. Возможно, мне не следовало бы иметь это в промежуточном программном обеспечении, может быть, где-то еще. Может быть, Polly для HttpClient - лучшее место.

Я открыт для любых идей.

Спасибо за любую помощь, которую вы можете предоставить.

Кодовое решение, которое у меня сработало


Спасибо Микаэлю Дерри за помощь и направление (не забудьте увидеть его ответ для получения дополнительной информации в контекст этого решения). Это решение, которое я придумал, и оно работает для меня:

options.Events = new CookieAuthenticationEvents
{
    OnValidatePrincipal = context =>
    {
        //check to see if user is authenticated first
        if (context.Principal.Identity.IsAuthenticated)
        {
            //get the user's tokens
            var tokens = context.Properties.GetTokens();
            var refreshToken = tokens.FirstOrDefault(t => t.Name == "refresh_token");
            var accessToken = tokens.FirstOrDefault(t => t.Name == "access_token");
            var exp = tokens.FirstOrDefault(t => t.Name == "expires_at");
            var expires = DateTime.Parse(exp.Value);
            //check to see if the token has expired
            if (expires < DateTime.Now)
            {
                //token is expired, let's attempt to renew
                var tokenEndpoint = "https://token.endpoint.server";
                var tokenClient = new TokenClient(tokenEndpoint, clientId, clientSecret);
                var tokenResponse = tokenClient.RequestRefreshTokenAsync(refreshToken.Value).Result;
                //check for error while renewing - any error will trigger a new login.
                if (tokenResponse.IsError)
                {
                    //reject Principal
                    context.RejectPrincipal();
                    return Task.CompletedTask;
                }
                //set new token values
                refreshToken.Value = tokenResponse.RefreshToken;
                accessToken.Value = tokenResponse.AccessToken;
                //set new expiration date
                var newExpires = DateTime.UtcNow + TimeSpan.FromSeconds(tokenResponse.ExpiresIn);
                exp.Value = newExpires.ToString("o", CultureInfo.InvariantCulture);
                //set tokens in auth properties 
                context.Properties.StoreTokens(tokens);
                //trigger context to renew cookie with new token values
                context.ShouldRenew = true;
                return Task.CompletedTask;
            }
        }
        return Task.CompletedTask;
    }
};

person bugnuker    schedule 04.09.2018    source источник
comment
Я следил за вашим кодом, потому что у меня аналогичное требование, я хочу, чтобы токен идентификатора имел доступ к моему api. Но когда я пытаюсь создать новый токен идентификатора, используя приведенный выше код, он создает токен. Но когда я использую id_token для авторизации своего API, он возвращается как неавторизованный. Любая идея?   -  person NewBieDevRo    schedule 09.07.2019
comment
Id_tokens не попадают в ваш API. access_tokens переходят в ваш API. Я бы порекомендовал вам протестировать и заставить ваш API работать с access_tokens, прежде чем вы начнете работать над тем, чтобы токены обновления работали. Токен обновления просто выдаст новый access_token   -  person bugnuker    schedule 12.07.2019
comment
Взгляните на этот lessprivilege.com/2019/01/14/ и github.com/IdentityServer/IdentityServer4/tree/master/samples/   -  person Suketu Bhuta    schedule 10.08.2019


Ответы (2)


Токен доступа и токен обновления хранятся в ядре ASP.NET.

Я думаю, важно отметить, что токены хранятся в файле cookie, который идентифицирует пользователя для вашего приложения.

Это мое мнение, но я не думаю, что настраиваемое промежуточное ПО - подходящее место для обновления токенов. Причина этого в том, что если вы успешно обновите токен, вам нужно будет заменить существующий и отправить его обратно в браузер в виде нового файла cookie, который заменит существующий.

Вот почему я думаю, что наиболее подходящим местом для этого является то, когда файл cookie считывается ASP.NET Core. Каждый механизм аутентификации предоставляет несколько событий; для файлов cookie есть тот, который называется ValidatePrincipal, который вызывается при каждом запросе после того, как файл cookie был прочитан и идентификатор был успешно десериализован из него.

public void ConfigureServices(ServiceCollection services)
{
    services
        .AddAuthentication()
        .AddCookies(new CookieAuthenticationOptions
        {
            Events = new CookieAuthenticationEvents
            {
                OnValidatePrincipal = context =>
                {
                    // context.Principal gives you access to the logged-in user
                    // context.Properties.GetTokens() gives you access to all the tokens

                    return Task.CompletedTask;
                }
            }
        });
}

Этот подход хорош тем, что если вам удастся обновить токен и сохранить его в AuthenticationProperties, переменная context, имеющая тип CookieValidatePrincipalContext, будет иметь свойство с именем

Приятно то, что если ваше приложение MVC разрешает доступ к нему только аутентифицированным пользователям, MVC позаботится о выдаче ответа HTTP 401, который система аутентификации поймает и превратит в вызов, и пользователь будет перенаправлен обратно к поставщику удостоверений. .

У меня есть код, который показывает, как это будет работать, в репозитории mderriey/TokenRenewal на GitHub. Хотя цель иная, она показывает механику использования этих событий.

person Mickaël Derriey    schedule 05.09.2018
comment
Кажется, это работает достаточно хорошо, спасибо! Я отправлю свое полное решение, чтобы обновить вопрос. Спасибо еще раз! - person bugnuker; 05.09.2018
comment
Рад, что это сработало для вас. Жажда увидеть решение, которое вы выберете. Удачи! - person Mickaël Derriey; 05.09.2018
comment
Теперь, когда я смотрю ваше решение на GitHub, ваш код немного чище моего: D - person bugnuker; 05.09.2018
comment
Этот код никогда не выполняется, потому что токен удаляется из браузера после отправки запроса. Или что мне не хватает? Когда срок действия файла cookie истек, он просто удаляется из браузера и не может быть обновлен, поскольку никогда не существовал. - person Deukalion; 02.10.2018
comment
@Deukalion Я не понимаю вашего комментария. Что вы имеете в виду под ... токен удаляется из браузера после отправки запроса? После отправки запроса браузер не удаляет файлы cookie. Браузер удаляет файлы cookie только после того, как истечет срок их действия или файл cookie был удален с использованием заголовка ответа Set-Cookie). Срок действия cookie полностью отделен от access_token истечения или refresh_token истечения срока и refresh_token отзыва. - person Dai; 14.07.2019

Я создал альтернативную реализацию, которая имеет некоторые дополнительные преимущества:

  • Совместим с ASP.NET Core v3.1
  • Повторно использует параметры конфигурации OpenID, переданные методу AddOpenIdConnect. Это немного упрощает настройку клиента.
  • Использует документ обнаружения Open ID Connect для определения конечной точки токена. Вы можете выбрать кэширование конфигурации, чтобы сэкономить дополнительный возврат к Identity Server.
  • Не блокирует поток во время вызовов аутентификации (асинхронная операция), улучшая масштабируемость.

Это обновленный OnValidatePrincipal метод:

private async Task OnValidatePrincipal(CookieValidatePrincipalContext context)
{
    const string accessTokenName = "access_token";
    const string refreshTokenName = "refresh_token";
    const string expirationTokenName = "expires_at";

    if (context.Principal.Identity.IsAuthenticated)
    {
        var exp = context.Properties.GetTokenValue(expirationTokenName);
        if (exp != null)
        {
            var expires = DateTime.Parse(exp, CultureInfo.InvariantCulture).ToUniversalTime();
            if (expires < DateTime.UtcNow)
            {
                // If we don't have the refresh token, then check if this client has set the
                // "AllowOfflineAccess" property set in Identity Server and if we have requested
                // the "OpenIdConnectScope.OfflineAccess" scope when requesting an access token.
                var refreshToken = context.Properties.GetTokenValue(refreshTokenName);
                if (refreshToken == null)
                {
                    context.RejectPrincipal();
                    return;
                }

                var cancellationToken = context.HttpContext.RequestAborted;

                // Obtain the OpenIdConnect options that have been registered with the
                // "AddOpenIdConnect" call. Make sure we get the same scheme that has
                // been passed to the "AddOpenIdConnect" call.
                //
                // TODO: Cache the token client options
                // The OpenId Connect configuration will not change, unless there has
                // been a change to the client's settings. In that case, it is a good
                // idea not to refresh and make sure the user does re-authenticate.
                var serviceProvider = context.HttpContext.RequestServices;
                var openIdConnectOptions = serviceProvider.GetRequiredService<IOptionsSnapshot<OpenIdConnectOptions>>().Get(OpenIdConnectScheme);
                var configuration = openIdConnectOptions.Configuration ?? await openIdConnectOptions.ConfigurationManager.GetConfigurationAsync(cancellationToken).ConfigureAwait(false);
                
                // Set the proper token client options
                var tokenClientOptions = new TokenClientOptions
                {
                    Address = configuration.TokenEndpoint,
                    ClientId = openIdConnectOptions.ClientId,
                    ClientSecret = openIdConnectOptions.ClientSecret
                };
                
                var httpClientFactory = serviceProvider.GetService<IHttpClientFactory>();
                using var httpClient = httpClientFactory.CreateClient();

                var tokenClient = new TokenClient(httpClient, tokenClientOptions);
                var tokenResponse = await tokenClient.RequestRefreshTokenAsync(refreshToken, cancellationToken: cancellationToken).ConfigureAwait(false);
                if (tokenResponse.IsError)
                {
                    context.RejectPrincipal();
                    return;
                }

                // Update the tokens
                var expirationValue = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn).ToString("o", CultureInfo.InvariantCulture);
                context.Properties.StoreTokens(new []
                {
                    new AuthenticationToken { Name = refreshTokenName, Value = tokenResponse.RefreshToken },
                    new AuthenticationToken { Name = accessTokenName, Value = tokenResponse.AccessToken },
                    new AuthenticationToken { Name = expirationTokenName, Value = expirationValue }
                });

                // Update the cookie with the new tokens
                context.ShouldRenew = true;
            }
        }
    }
}

person Ramon de Klein    schedule 23.04.2020
comment
Рамон благодарит за ваш код var openIdConnectOptions = serviceProvider.GetRequiredService ‹IOptionsSnapshot ‹OpenIdConnectOptions›› (). Get (OpenIdConnectScheme); что такое OpenIdConnectScheme? - person Ajt; 06.05.2020
comment
У меня два вопроса относительно этого примера. Где вы назовете этот метод в своем потоке? частная асинхронная задача OnValidatePrincipal (контекст CookieValidatePrincipalContext) для подключения А что делает этот TokenClient? Это наверное с IdentityServer? Я не могу найти для него код. - Как я могу создать этот TokenClient без, используя IdentityServer? var tokenClient = новый TokenClient (httpClient, tokenClientOptions); var tokenResponse = ожидание tokenClient.RequestRefreshTokenAsync (refreshToken, cancellationToken: cancellationToken) .ConfigureAwait (false); - person Lord02; 23.11.2020
comment
Удалось зарегистрировать настраиваемый обработчик следующим образом: startup.cs: .AddCookie (options = ›{... // Это будет обрабатывать выпуск новых refresh_tokens для протокола OpenId: options.EventsType = typeof (CustomCookieAuthEvents); custom class: public class CustomCookieAuthEvents: CookieAuthenticationEvents {общедоступная асинхронная задача ValidatePrincipal (CookieValidatePrincipalContext context) Теперь мне нужен только TokenClient ... у вас есть реализация для этого? tokenClient.RequestRefreshTokenAsync - person Lord02; 23.11.2020