Как выполнить интеграционный тест на конечной точке контроллера API ASP.NET Core, требующей аутентификации и проверки токенов защиты от подделки

Описание

У меня есть конечная точка контроллера API ASP.NET Core, для которой требуется:

  • аутентифицированный пользователь и
  • проверка антиподдельных токенов

Я хочу выполнить интеграционный тест на этой конечной точке.

Проблема

Я не могу отправить запрос, в котором есть как аутентифицированный пользователь, так и необходимые токены/куки-файлы защиты от подделки и куки-файлы аутентификации. Таким образом, конечная точка продолжает возвращать Bad Request, прежде чем достигнет обработчика.

Вопрос

Как выполнить интеграционный тест на конечной точке, требующей как аутентификации, так и проверки токенов защиты от подделки?

Код

Чтобы помочь разобраться в этом вопросе, я создал образец приложения, чтобы продемонстрировать мою проблему.

Конечные точки контроллера API

В примере приложения имеется 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 и никогда не достигает обработчика.

У меня нет ответа, но после аутентификации антиподделочные токены обязательно придется поменять. У вас будет другой сеанс. (или у вас должен быть другой сеанс, иначе вы будете уязвимы для атаки с фиксацией сеанса). Убедитесь, что вы создаете пару токенов ПОСЛЕ входа в систему.

browsermator 19.07.2024 00:42

Спасибо за совет. Я обновил метод GetAuthenticationCookies , чтобы получить собственные токены защиты от подделки, и в интеграционном тесте я снова получаю токены защиты от подделки после входа в систему. К сожалению, я все еще получаю возврат BadRequest.

zwoolli 19.07.2024 07:15

вы можете попробовать использовать antiforgery.GetAndStoreTokens вместо GetTokens: Learn.microsoft.com/en-us/dotnet/api/… Убедитесь, что следующий запрос использует их. Убедитесь, что все они используют один и тот же сеанс/httpclient.

browsermator 19.07.2024 17:24

Я вижу, что GetAndStoreTokens хранит токен защиты от подделки в заголовках ответа HttpContext. Я уже могу добавить токены в заголовок HttpClient. Но мне трудно использовать один и тот же HttpClient для нескольких запросов. В некоторых случаях я использую HttpClient для получения информации (например, токенов) для создания нового HttpClient. Я попробовал изменить GetTokens на GetAndStoreTokens. Это все равно возвращает неверный запрос...

zwoolli 20.07.2024 07:38

стоило бы изучить запросы.... но токен защиты от подделки будет привязан к сеансу и форме (или предыдущему запросу/ответу). Ключ меняется для каждой формы/запроса. Сеанс начинается с авторизации, которая используется для получения значения защиты от подделки. Возможно, будет проще просто высмеять весь ответ с помощью формы. (@antiforgery helper создаст токен, который необходимо использовать в опубликованной форме... он вызывает getandstoretokens) Файл cookie не меняется, пока не изменится сеанс, но зашифрованная версия (скрытое поле в стандартных сообщениях) будет меняться для каждой формы.

browsermator 22.07.2024 19:01

Могу я спросить: Где именно Antiforgery заявлен как услуга? это часть кода, которую мы не видим в примере?

Pedro Costa 23.07.2024 18:37

@PedroCosta - класса Antiforgery нет. Однако существует класс AntiforgeryTokens, который по сути представляет собой просто POCO, хранящий значения, связанные с данным токеном защиты от подделки.

zwoolli 24.07.2024 06:39

@browsermator - Я думаю, что могут возникнуть проблемы с получением файлов cookie аутентификации. Чтобы получить файлы cookie аутентификации, я вхожу в приложение в конечной точке входа , которая использует ApplicationScheme . Когда получаю аутентифицированного клиента для тестирования, я использую «TestScheme».

zwoolli 24.07.2024 06:47

вам следует немного отладить, чтобы определить, какой именно запрос вызывает ошибку, но теперь, когда я смотрю на program.cs, он выглядит как стандартный материал для защиты от подделки, поэтому не уверен, почему вы это используете: client.DefaultRequestHeaders.Add(tokens.HeaderName, tokens .RequestToken); Стандартные средства защиты от подделки будут использовать файлы cookie и скрытое поле (в теле POST)... в заголовках ничего не должно быть (файлы cookie будут отправлены, но ничего больше). Скрытое поле должно соответствовать набору токенов, созданному во время предыдущего ответа. (Единственное, что меняется, это скрытое поле... файл cookie остается неизменным для каждого сеанса)

browsermator 25.07.2024 20:30

просто чтобы уточнить: если IAntiforgeryOptions установил headerName, он будет ожидать токен защиты от подделки через заголовок. По умолчанию это ожидается от скрытого поля. HeaderName предназначен для запросов Javascript SPA. Не задавайте options.HeaderName. «HeaderName Имя заголовка, используемое системой защиты от подделки. Если значение равно нулю, система учитывает только данные формы.»: Learn.microsoft.com/en-us/aspnet/core/security/…

browsermator 25.07.2024 20:40

Не уверен, что эти конечные точки вообще много проверяют... они просто получают имя из URL-адреса? Не проверяете отправленные данные?

browsermator 25.07.2024 20:56

nvm, теперь я понял, фильтры должны запускаться до запуска метода... если аутентификация не удалась, он должен вернуть другую ошибку... только отсутствие надлежащей защиты от подделки возвращает неправильный запрос.... (или неверный запрос ) Похоже, что в запросах 3 и 4 отсутствуют данные формы. Так что до сих пор не уверен, почему 3 работает. Но это может быть связано с чтением данных GET. Я бы изменил эти конечные точки, чтобы они читали имя поля формы вместо получения значения через URL-адрес (который обычно представляет собой GET, защита от подделки не требуется). Затем обязательно отправьте данные формы POST для 3 и 4 тестов.... со скрытым полем для защиты от подделки.

browsermator 25.07.2024 21:37

@browsermator — пример приложения представляет собой контроллер API, поэтому никакой формы не будет. Microsoft предлагает отправлять токены защиты от подделки в заголовке запроса.

zwoolli 27.07.2024 07:48
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
4
13
168
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

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

zwoolli 27.07.2024 07:38

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