devpav
@devpav
Full-Stack разработчик.

Как в C# на этапе AddCookie[options.Events.OnValidatePrincipal] проверять активность сессии пользователя в стороннем auth-server?

Привет, дорогой друг!

Не судите строго. Расскажу полную историю:

Есть backend С# (.NET) использую сторонний сервис аутентификации/авторизации
spoiler
на спринговый (java) (sso)
.
Есть frontend под все сервисы.

backend С# (.NET) - выступает auth2 клиентом auth сервера.

self.AddAuthentication(options =>
            {
                options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            })
            .AddCookie(options =>
            {
                options.Events.OnValidatePrincipal = context =>
                {
                    var descriptor = context.HttpContext.GetEndpoint()?.Metadata.OfType<ControllerActionDescriptor>().FirstOrDefault();

                    if (descriptor != null && DISABLEDCONTROLLER.TryGetValue(descriptor.ControllerTypeInfo, out var exception) &&
                        exception.Contains(descriptor.MethodInfo.Name))
                    {
                        return Task.CompletedTask;
                    }

                    var principalValidator = context.HttpContext.RequestServices.GetRequiredService<PrincipalValidator>();
                    return principalValidator.ValidateAsync(context);
                };

                options.SessionStore = self.BuildServiceProvider().GetRequiredService<ITicketStore>();
                options.Cookie.HttpOnly = true;
                options.Cookie.Name = authorizationService.CookieName;
                options.Cookie.Domain = authorizationService.CookieDomain;

                options.Events.OnRedirectToAccessDenied = context =>
                {
                    context.Response.StatusCode = 403;
                    context.Response.WriteAsJsonAsync(new MessageResponse { Message = "Forbidden", });
                    return Task.CompletedTask;
                };
                options.Events.OnRedirectToLogin = context =>
                {
                    context.Response.StatusCode = 401;
                    context.Response.WriteAsJsonAsync(new MessageResponse { Message = "Unauthorized", });
                    return Task.CompletedTask;
                };
            })
            .AddOAuth(authorizationService.Id, options =>
            {
                options.ClientId = authorizationService.ClientId;
                options.ClientSecret = authorizationService.ClientSecret;

                .... // другой код.
            });


Вообщем все прекрасно работает (аутентификация, авторизация[на основе ролей auth-server]) пока не вступает в силу проверка на каждом запросе access_token через callback options.Events.OnValidatePrincipal.

Обычная история: достаем сохраненные tokens (access, refresh, exp_t). проверяем exp_t < current time. идем рефрешить access_token через рефреш токен и получаем новый токен access_token и инстайлим в properties.StoreTokens.

Работает в postman - смело запускаю front и проверяю.


Не работает. Почему? в одно время frontend делает много запросов, что приводит к тому что оба попадают на refresh_token request. Один отрабатывает, а второй запрос падает (refresh_token) уже использован. Таким образом появилась критическая секция (каждый поток пытается обогнать). В тот же момент уже кто-то идет на проверку access_token и происходит context.RejectPrincipal(); и frontend естественно от других запросов получает 401 (всю аутентификацию сбросили).

Пробовал синхрить по записи в redis на lock. Не работает.

Полный код компонента:

public class PrincipalValidator
{
    private readonly ILogger<PrincipalValidator> _logger;
    private readonly TokenService _tokenService;

    public PrincipalValidator(ILogger<PrincipalValidator> logger,
                                     TokenService tokenService)
    {
        _logger = logger;
        _tokenService = tokenService;
    }

    public async Task ValidateAsync(CookieValidatePrincipalContext context)
    {
        if (context is null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        if (!context.HttpContext.Request.Path.StartsWithSegments("/api"))
        {
            return;
        }

        try
        {
            var result = await HandleAsync(context);

            switch (result)
            {
                case Result.RefreshToken:
                    _logger.LogInformation("The token has been successfully updated");
                    break;
                case Result.RejectPrincipal:
                    return;
                case Result.None:
                    _logger.LogInformation("The token does not need to be updated");
                    break;
                case Result.Locked:
                    return;
                default:
                    _logger.Log(LogLevel.Error, "tokenResponse is RefreshToken or AccessToken is null");
                    throw new ArgumentNullException(nameof(result));
            }

            var accessToken = context.Properties.GetTokenValue(TokenDefaults.AccessTokenName);

            if (accessToken is null)
            {
                context.RejectPrincipal();
                return;
            }

            using var tokenPrincipal =
                await _tokenService.GetTokenPrincipalAsync(accessToken, context.HttpContext.RequestAborted);

            if (!tokenPrincipal.RootElement.TryGetProperty("active", out var active))
            {
                context.RejectPrincipal();
                return;
            }

            if (!active.GetBoolean())
            {
                _logger.LogInformation(@"Token refresh operation has been with result {result}", tokenPrincipal);
                context.RejectPrincipal();
            }
        }
        catch (Exception e)
        {
            context.RejectPrincipal();
            _logger.LogError(e, "during token validation");
        }
    }

    private async Task<Result> HandleAsync(CookieValidatePrincipalContext context)
    {
        if (context.Principal?.Identity is { IsAuthenticated: false })
        {
            _logger.Log(LogLevel.Information, "Principal is not authenticated");
            return Result.None;
        }

        var exp = context.Properties.GetTokenValue(TokenDefaults.ExpirationTokenName);

        if (exp is null)
        {
            _logger.Log(LogLevel.Information, "Exp is null. not refreshed");
            return Result.None;
        }

        _logger.LogInformation(@"get exp {exp}", exp);

        var expTime = DateTime.Parse(exp, CultureInfo.InvariantCulture).ToUniversalTime();

        _logger.LogInformation(@"utc exp {expires}", expTime);
        if (expTime > DateTime.UtcNow)
        {
            _logger.Log(LogLevel.Information, "expires >= DateTime.UtcNow");
            return Result.None;
        }

        var refreshToken = context.Properties.GetTokenValue(TokenDefaults.AccessTokenName);

        if (refreshToken is null)
        {
            _logger.Log(LogLevel.Warning, "refreshToken is null");
            context.RejectPrincipal();
            return Result.RejectPrincipal;
        }

        return await RefreshTokenAsync(context, context.HttpContext.RequestAborted);
    }

    private async Task<Result> RefreshTokenAsync(CookieValidatePrincipalContext context, CancellationToken cancellationToken)
    {
        var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<HttpContext>>();

        var refreshToken = context.Properties.GetTokenValue(TokenDefaults.RefreshTokenName);

        if (refreshToken is null)
        {
            logger.Log(LogLevel.Warning, "refreshToken is null");
            return Result.RejectPrincipal;
        }

        RefreshTokenObject? tokenResponse;

        try
        {
            var authenticationHandler = context.HttpContext.RequestServices.GetRequiredService<TokenService>();

            tokenResponse = await authenticationHandler.RefreshTokenAsync(refreshToken, cancellationToken);
        }
        catch (Exception ex)
        {
            logger.LogDebug("token refresh error {}", ex.Message);
            return Result.RejectPrincipal;
        }

        switch (tokenResponse)
        {
            case null:
                logger.Log(LogLevel.Error, "tokenResponse is null");

                return Result.RejectPrincipal;
            case { RefreshToken: null } or { AccessToken: null }:
                logger.Log(LogLevel.Error, "tokenResponse is RefreshToken or AccessToken is null");

                return Result.RejectPrincipal;
        }

        var expirationValue = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn!.Value)
            .ToString("o", CultureInfo.InvariantCulture);

        context.Properties.StoreTokens(new[]
        {
            new AuthenticationToken { Name = TokenDefaults.RefreshTokenName, Value = tokenResponse.RefreshToken },
            new AuthenticationToken { Name = TokenDefaults.AccessTokenName, Value = tokenResponse.AccessToken },
            new AuthenticationToken { Name = TokenDefaults.ExpirationTokenName, Value = expirationValue },
        });

        context.ShouldRenew = true;

        logger.Log(LogLevel.Information, "Token refreshed");

        return Result.RefreshToken;
    }
}


Подскажите, как делают refresh_token взрослые дяди в многопоточке. Не блокируя потоки. Буду безумно благодарен и рад любому комментарию.

В C# совсем недавно не судите строго.
  • Вопрос задан
  • 84 просмотра
Решения вопроса 1
@mvv-rus
Настоящий админ AD и ненастоящий программист
Подскажите, как делают refresh_token взрослые дяди в многопоточке.

Если нужен ответ на это конкретный вопрос, причем если имеется в виду не просто многопоточка, а асинхронная, с await без блокировки потока - то на это есть такой SemaphoreSlim. Делается примерно так (надеюсь, идея будет понятна)
//Попадаем сюда после получения существующего access token и выявления, что он просрочен
    SemaphoreSlim sem = GetSemaphore(clientId); 
   await sem.WaitAsync(); //timeot и CancellationToken добавить по вкусу
   try {
     //Получаем существующий access token повторно - вдруг его уже кто-нибудь до нас обновил 
    //   (используем double check pattern)
    //Если это не так,  выполняем тут всю логику обновления access token
  }
  finally {
      sem.Release(); //SemaphoreSlim - не мьютекс, сам не освободится в случае чего
  }


GetSemaphore реализовать можно по-разному. Можно один на все приложение: static или Singleton - это если нагрузка небольшая.
А можно кэшировать семафоры по одному для каждого пользователя (т.е. свой семафор для каждого clientID), чтобы пользователи не толклись около одного семафора на всех.
Главное, чтобы этот семафор создавался с начальным значением 1 - тогда он будет пускать пользователей по одному.

Ну, а если все делать без асинхронности, в одном потоке, блокируя его при необходимости (т.е. без await), то способов много. Простейший - блок lock вокруг кода обновления маркера доступа (access token), есть такде Monitor, Mutex, тот же Semaphore (хоть со Slim, хоть без)...
Ответ написан
Пригласить эксперта
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Войти через центр авторизации
Похожие вопросы