У меня есть конечная точка контроллера API ASP.NET Core, для которой требуется:
Я хочу выполнить интеграционный тест на этой конечной точке.
Я не могу отправить запрос, в котором есть как аутентифицированный пользователь, так и необходимые токены/куки-файлы защиты от подделки и куки-файлы аутентификации. Таким образом, конечная точка продолжает возвращать Bad Request
, прежде чем достигнет обработчика.
Как выполнить интеграционный тест на конечной точке, требующей как аутентификации, так и проверки токенов защиты от подделки?
Чтобы помочь разобраться в этом вопросе, я создал образец приложения, чтобы продемонстрировать мою проблему.
В примере приложения имеется API-контроллер с четырьмя конечными точками POST:
1. Неаутентифицированная (анонимная) конечная точка — проверка на подделку НЕ требуется.
[AllowAnonymous]
[HttpPost("Anonymous/{name}")]
public IActionResult AnonymousPost(string name)
{
return Ok(name);
}
2. Аутентифицированная конечная точка — проверка на подделку НЕ требуется.
[HttpPost("Authenticated/{name}")]
public IActionResult AuthenticatedPost(string name)
{
return Ok(name);
}
3. Неаутентифицированная (анонимная) конечная точка — требуется проверка на подделку.
[AllowAnonymous]
[ValidateAntiForgeryToken]
[HttpPost("Anonymous/Antiforgery/{name}")]
public IActionResult AnonymousAntiforgeryPost(string name)
{
return Ok(name);
}
4. Аутентифицированная конечная точка – требуется проверка на защиту от подделки.
[ValidateAntiForgeryToken]
[HttpPost("Authenticated/Antiforgery/{name}")]
public IActionResult AuthenticatedAntiforgeryPost(string name)
{
return Ok(name);
}
Конечная точка № 4, для которой требуется аутентифицированный пользователь, а также проверка токенов защиты от подделки, — это конечная точка, которую я не могу успешно протестировать.
Приложение использует аутентификацию с использованием файлов cookie и требует наличия аутентифицированного пользователя.
// Add authentication
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = IdentityConstants.ApplicationScheme;
}).AddCookie(IdentityConstants.ApplicationScheme, options =>
{
options.LoginPath = new PathString("/Login");
}).AddTwoFactorRememberMeCookie();
// Add authorization
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
Тестовый проект настраивает тестовую схему аутентификации
public static IWebHostBuilder ConfigureTestAuthenticationScheme(this IWebHostBuilder builder, string scheme)
{
ArgumentNullException.ThrowIfNull(builder);
return builder.ConfigureTestServices(services =>
{
services.AddAuthentication(defaultScheme: "TestScheme")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("TestScheme", options => { });
});
}
Где TestAuthHandler наследует от AuthenticatonHandler и переопределяет метод HandleAuthenticateAsync
.
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
Claim[] claims =
[
new Claim(ClaimTypes.Name, "testuser"),
new Claim(ClaimTypes.NameIdentifier, "testuser")
];
ClaimsIdentity identity = new (claims, "Test");
ClaimsPrincipal principal = new (identity);
AuthenticationTicket ticket = new (principal, "TestScheme");
AuthenticateResult result = AuthenticateResult.Success(ticket);
return Task.FromResult(result);
}
аутентифицированный клиент можно создать для теста следующим образом:
public HttpClient GetAuthenticatedClient(CookieContainerHandler? cookieHandler = default)
{
cookieHandler ??= new();
string testScheme = "TestScheme";
HttpClient client = WithWebHostBuilder(builder =>
{
builder.ConfigureTestAuthenticationScheme(testScheme);
})
.CreateDefaultClient(cookieHandler);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(scheme: testScheme);
return client;
}
Атрибут ValidateAntiForgeryToken используется на двух конечных точках (#3 и #4), которые требуют проверки токена защиты от подделки.
Тестовый проект добавляет AntiforgeryController к IWebHostBuilder
, который возвращает объект JSON, содержащий действительные токены защиты от подделки.
public static IWebHostBuilder ConfigureAntiforgeryTokenResource(this IWebHostBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
return builder.ConfigureTestServices((services) =>
{
services.AddControllers()
.AddApplicationPart(typeof(AntiforgeryTokenController).Assembly);
});
}
public IActionResult GetAntiforgeryTokens(
[FromServices] IAntiforgery antiforgery,
[FromServices] IOptions<AntiforgeryOptions> options)
{
ArgumentNullException.ThrowIfNull(antiforgery);
ArgumentNullException.ThrowIfNull(options);
AntiforgeryTokenSet tokens = antiforgery.GetTokens(HttpContext);
AntiforgeryTokens model = new()
{
CookieName = options.Value!.Cookie!.Name!,
CookieValue = tokens.CookieToken!,
FormFieldName = options.Value.FormFieldName,
HeaderName = tokens.HeaderName!,
RequestToken = tokens.RequestToken!
};
return Json(model);
}
CustomWebApplicationFactory предоставляет метод GetAntiForgeryTokensAsync
для проверки связи AntiforgeryTokenController
внутри тестового метода.
public async Task<AntiforgeryTokens> GetAntiforgeryTokensAsync(
Func<HttpClient>? httpClientFactory = null,
CancellationToken cancellationToken = default)
{
using HttpClient httpClient = httpClientFactory?.Invoke() ?? CreateDefaultClient();
AntiforgeryTokens? tokens = await httpClient.GetFromJsonAsync<AntiforgeryTokens>(
AntiforgeryTokenController.GetTokensUri,
cancellationToken);
return tokens!;
}
Я могу успешно протестировать первые три конечные точки, однако, когда мне нужно протестировать четвертую конечную точку, требующую как аутентифицированного пользователя, так и проверки токена защиты от подделки, возвращается Bad Request
.
1. Тестирование неаутентифицированной (анонимной) конечной точки — проверка на подделку НЕ требуется.
public async Task Unauthenticated_request_to_anonymous_endpoint_returns_ok()
{
// Arrange
HttpRequestMessage message = new()
{
Method = HttpMethod.Post,
RequestUri = new Uri("/api/anonymous/testname", UriKind.Relative)
};
// Act
HttpResponseMessage response = await _client.SendAsync(message);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
2. Тестирование аутентифицированной конечной точки — проверка на подделку НЕ требуется.
public async Task Authenticated_request_to_autheticated_endpoint_returns_ok()
{
// Arrange
HttpClient client = _factory.GetAuthenticatedClient();
HttpRequestMessage message = new()
{
Method = HttpMethod.Post,
RequestUri = new Uri("/api/authenticated/testname", UriKind.Relative)
};
// Act
HttpResponseMessage response = await client.SendAsync(message);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
3. Тестирование неаутентифицированной (анонимной) конечной точки — требуется проверка на защиту от подделки.
public async Task Unauthenticated_request_to_anonymous_antiforgery_endpoint_with_tokens_returns_ok()
{
// Arrange
AntiforgeryTokens tokens = await _factory.GetAntiforgeryTokensAsync();
CookieContainerHandler cookieHandler = new();
cookieHandler.Container.Add(
_factory.Server.BaseAddress,
new Cookie(tokens.CookieName, tokens.CookieValue));
HttpClient client = _factory.CreateDefaultClient(cookieHandler);
client.DefaultRequestHeaders.Add(tokens.HeaderName, tokens.RequestToken);
HttpRequestMessage message = new()
{
Method = HttpMethod.Post,
RequestUri = new Uri("/api/anonymous/antiforgery/testname", UriKind.Relative)
};
// Act
HttpResponseMessage response = await client.SendAsync(message);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
4. Тестирование аутентифицированной конечной точки — требуется проверка на защиту от подделки.
Я читал браузер автоматически извлекает любые файлы cookie из заголовков ответов сервера и прикрепляет их к следующему запросу. Чтобы проверка с помощью CSRF прошла успешно, ее необходимо смоделировать. Поэтому этот тест вызывает метод GetAuthenticationCookies, который входит в приложение и извлекает файлы cookie аутентификации из ответа.
public async Task<List<string>> GetAuthenticationCookies(CookieContainerHandler cookieHandler, AntiforgeryTokens tokens)
{
CancellationToken cancellationToken = new CancellationTokenSource().Token;
HttpClient client = _factory.CreateDefaultClient(cookieHandler);
Uri uri = new($"{client.BaseAddress!.AbsoluteUri}login");
Dictionary<string, string> postData = new()
{
{ "Input.UserName", "testuser" },
{ "Input.Password", "password" },
{ tokens!.FormFieldName, tokens.RequestToken }
};
HttpContent formContent = new FormUrlEncodedContent(postData);
HttpResponseMessage response = await client.PostAsync(uri, formContent, cancellationToken);
return response.Headers.GetValues("Set-Cookie").ToList();
}
Затем тест добавляет эти файлы cookie в заголовки запросов клиентов в дополнение к токенам защиты от подделки.
public async Task Authenticated_request_to_authenticated_antiforgery_endpoint_with_tokens_returns_ok()
{
// Arrange
AntiforgeryTokens tokens = await _factory.GetAntiforgeryTokensAsync();
CookieContainerHandler cookieHandler = new();
cookieHandler.Container.Add(
_factory.Server.BaseAddress,
new Cookie(tokens.CookieName, tokens.CookieValue));
HttpClient client = _factory.GetAuthenticatedClient(cookieHandler);
List<string> cookies = await GetAuthenticationCookies(cookieHandler, tokens);
client.DefaultRequestHeaders.Add(tokens.HeaderName, tokens.RequestToken);
client.DefaultRequestHeaders.Add("Cookie", cookies);
HttpRequestMessage message = new()
{
Method = HttpMethod.Post,
RequestUri = new Uri("/api/authenticated/antiforgery/testname", UriKind.Relative)
};
// Act
HttpResponseMessage response = await client.SendAsync(message);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
К сожалению, этот тест возвращает Bad Request
и никогда не достигает обработчика.
Спасибо за совет. Я обновил метод GetAuthenticationCookies , чтобы получить собственные токены защиты от подделки, и в интеграционном тесте я снова получаю токены защиты от подделки после входа в систему. К сожалению, я все еще получаю возврат BadRequest
.
вы можете попробовать использовать antiforgery.GetAndStoreTokens вместо GetTokens: Learn.microsoft.com/en-us/dotnet/api/… Убедитесь, что следующий запрос использует их. Убедитесь, что все они используют один и тот же сеанс/httpclient.
Я вижу, что GetAndStoreTokens хранит токен защиты от подделки в заголовках ответа HttpContext. Я уже могу добавить токены в заголовок HttpClient. Но мне трудно использовать один и тот же HttpClient для нескольких запросов. В некоторых случаях я использую HttpClient для получения информации (например, токенов) для создания нового HttpClient. Я попробовал изменить GetTokens
на GetAndStoreTokens
. Это все равно возвращает неверный запрос...
стоило бы изучить запросы.... но токен защиты от подделки будет привязан к сеансу и форме (или предыдущему запросу/ответу). Ключ меняется для каждой формы/запроса. Сеанс начинается с авторизации, которая используется для получения значения защиты от подделки. Возможно, будет проще просто высмеять весь ответ с помощью формы. (@antiforgery helper создаст токен, который необходимо использовать в опубликованной форме... он вызывает getandstoretokens) Файл cookie не меняется, пока не изменится сеанс, но зашифрованная версия (скрытое поле в стандартных сообщениях) будет меняться для каждой формы.
Могу я спросить: Где именно Antiforgery заявлен как услуга? это часть кода, которую мы не видим в примере?
@PedroCosta - класса Antiforgery
нет. Однако существует класс AntiforgeryTokens, который по сути представляет собой просто POCO, хранящий значения, связанные с данным токеном защиты от подделки.
@browsermator - Я думаю, что могут возникнуть проблемы с получением файлов cookie аутентификации. Чтобы получить файлы cookie аутентификации, я вхожу в приложение в конечной точке входа , которая использует ApplicationScheme . Когда получаю аутентифицированного клиента для тестирования, я использую «TestScheme».
вам следует немного отладить, чтобы определить, какой именно запрос вызывает ошибку, но теперь, когда я смотрю на program.cs, он выглядит как стандартный материал для защиты от подделки, поэтому не уверен, почему вы это используете: client.DefaultRequestHeaders.Add(tokens.HeaderName, tokens .RequestToken); Стандартные средства защиты от подделки будут использовать файлы cookie и скрытое поле (в теле POST)... в заголовках ничего не должно быть (файлы cookie будут отправлены, но ничего больше). Скрытое поле должно соответствовать набору токенов, созданному во время предыдущего ответа. (Единственное, что меняется, это скрытое поле... файл cookie остается неизменным для каждого сеанса)
просто чтобы уточнить: если IAntiforgeryOptions установил headerName, он будет ожидать токен защиты от подделки через заголовок. По умолчанию это ожидается от скрытого поля. HeaderName предназначен для запросов Javascript SPA. Не задавайте options.HeaderName. «HeaderName Имя заголовка, используемое системой защиты от подделки. Если значение равно нулю, система учитывает только данные формы.»: Learn.microsoft.com/en-us/aspnet/core/security/…
Не уверен, что эти конечные точки вообще много проверяют... они просто получают имя из URL-адреса? Не проверяете отправленные данные?
nvm, теперь я понял, фильтры должны запускаться до запуска метода... если аутентификация не удалась, он должен вернуть другую ошибку... только отсутствие надлежащей защиты от подделки возвращает неправильный запрос.... (или неверный запрос ) Похоже, что в запросах 3 и 4 отсутствуют данные формы. Так что до сих пор не уверен, почему 3 работает. Но это может быть связано с чтением данных GET. Я бы изменил эти конечные точки, чтобы они читали имя поля формы вместо получения значения через URL-адрес (который обычно представляет собой GET, защита от подделки не требуется). Затем обязательно отправьте данные формы POST для 3 и 4 тестов.... со скрытым полем для защиты от подделки.
@browsermator — пример приложения представляет собой контроллер API, поэтому никакой формы не будет. Microsoft предлагает отправлять токены защиты от подделки в заголовке запроса.
Ошибка, с которой вы столкнулись, связана с тем, что в вашем тесте токен защиты от подделки получен без аутентификации, поэтому, когда к вашему API делается аутентифицированный запрос, токен защиты от подделки недействителен.
Одним из способов решения этой проблемы является изменение GetAntiforgeryTokensAsync для использования аутентифицированного клиента на основе параметра:
public async Task<AntiforgeryTokens> GetAntiforgeryTokensAsync(
Func<HttpClient>? httpClientFactory = null,
bool isAuthenticated = false,
CancellationToken cancellationToken = default)
{
using HttpClient httpClient = isAuthenticated
? this.GetAuthenticatedClient()
: (httpClientFactory?.Invoke() ?? CreateDefaultClient());
AntiforgeryTokens? tokens = await httpClient.GetFromJsonAsync<AntiforgeryTokens>(
AntiforgeryTokenController.GetTokensUri,
cancellationToken);
return tokens!;
}
Затем вы можете изменить свои тесты, которым нужны аутентифицированные токены защиты от подделки, чтобы установить этот параметр.
[Fact]
public async Task Authenticated_request_to_authenticated_antiforgery_endpoint_with_tokens_returns_ok()
{
// Arrange
List<string> cookies = await GetAuthenticationCookies();
AntiforgeryTokens tokens = await _factory.GetAntiforgeryTokensAsync(isAuthenticated: true);
CookieContainerHandler cookieHandler = new();
cookieHandler.Container.Add(
_factory.Server.BaseAddress,
new Cookie(tokens.CookieName, tokens.CookieValue));
HttpClient client = _factory.GetAuthenticatedClient(cookieHandler);
client.DefaultRequestHeaders.Add(tokens.HeaderName, tokens.RequestToken);
client.DefaultRequestHeaders.Add("Cookie", cookies);
HttpRequestMessage message = new()
{
Method = HttpMethod.Post,
RequestUri = new Uri("/api/authenticated/antiforgery/testname", UriKind.Relative)
};
// Act
HttpResponseMessage response = await client.SendAsync(message);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
Это сработало прекрасно! Я также удалил метод GetAuthenticatonCookies
— он был ненужен. Пример приложения был обновлен и теперь отражает рабочий код.
У меня нет ответа, но после аутентификации антиподделочные токены обязательно придется поменять. У вас будет другой сеанс. (или у вас должен быть другой сеанс, иначе вы будете уязвимы для атаки с фиксацией сеанса). Убедитесь, что вы создаете пару токенов ПОСЛЕ входа в систему.