Обработка устаревших токенов обновления в 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 из промежуточного ПО, и, возможно, другое промежуточное ПО также пытается получить доступ к контексту.

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

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

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

Code Solution, который работал для меня


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

options.Events = new CookieAuthenticationEvents
{
    OnValidatePrincipal = context =>
    {
        //check to see if user is authenticated first
        if (context.Principal.Identity.IsAuthenticated)
        {
            //get the users 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;
    }
};

1 ответ

Решение

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

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

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

Вот почему я думаю, что наиболее подходящее место для этого - это когда cookie читается ASP.NET Core. Каждый механизм аутентификации выставляет несколько событий; для печенья есть одна называется 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, имеет свойство под названием ShouldRenew, Установка этого свойства в true поручает промежуточному программному обеспечению выпустить новый файл cookie.

Если вы не можете обновить токен или обнаружите, что срок действия маркера обновления истек, и вы хотите предотвратить продвижение пользователя, этот же класс имеет RejectPrincipal метод, который инструктирует промежуточное программное обеспечение cookie обрабатывать запрос, как если бы он был анонимным.

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

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

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

  • Совместим с 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;
            }
        }
    }
}

Другие вопросы по тегам