Привет, дорогой друг!
Не судите строго. Расскажу полную историю:
Есть 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# совсем недавно не судите строго.