Files
tiku-backend.net/Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

180 lines
8.3 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Tiku.Api.Contracts;
using Tiku.Api.Options;
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
namespace Tiku.IntegrationTests.Api;
public sealed class BrowserAuthenticationTests
{
[Fact]
public async Task Password_login_uses_secure_cookies_and_does_not_return_tokens()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
const string phone = "13890000000";
var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "Browser User" }.WithTestPassword();
await factory.SeedAsync(
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Browser Tenant" },
user,
new TenantMembership
{
TenantId = tenantId,
UserId = user.Id,
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = false
});
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
client.DefaultRequestHeaders.Add("Origin", "http://localhost");
var response = await client.PostAsJsonAsync("/api/tenant/auth/browser/login/password", new PasswordLoginDto
{
Realm = AuthRealm.Tenant,
TenantCode = tenantId.ToString("N"),
Identifier = phone,
Password = PasswordTestUserExtensions.TestPassword
});
var body = await response.Content.ReadAsStringAsync();
using var json = JsonDocument.Parse(body);
var cookies = response.Headers.GetValues("Set-Cookie").ToArray();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.DoesNotContain("accessToken", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("refreshToken", body, StringComparison.OrdinalIgnoreCase);
Assert.Equal("Authenticated", json.RootElement.GetProperty("status").GetString());
Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.AccessCookie) &&
value.Contains("secure", StringComparison.OrdinalIgnoreCase) &&
value.Contains("httponly", StringComparison.OrdinalIgnoreCase));
Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.RefreshCookie) &&
value.Contains("samesite=strict", StringComparison.OrdinalIgnoreCase));
Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.CsrfCookie));
}
[Fact]
public async Task Browser_refresh_requires_same_origin_and_matching_csrf_token()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
const string phone = "13890000001";
var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "CSRF User" }.WithTestPassword();
await factory.SeedAsync(
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "CSRF Tenant" },
user,
new TenantMembership
{
TenantId = tenantId,
UserId = user.Id,
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = false
});
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
using var login = new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/browser/login/password")
{
Content = JsonContent.Create(new PasswordLoginDto
{
Realm = AuthRealm.Tenant,
TenantCode = tenantId.ToString("N"),
Identifier = phone,
Password = PasswordTestUserExtensions.TestPassword
})
};
login.Headers.Add("Origin", "http://localhost");
var loginResponse = await client.SendAsync(login);
var setCookies = loginResponse.Headers.GetValues("Set-Cookie").ToArray();
var refresh = ReadCookie(setCookies, BrowserAuthOptions.RefreshCookie);
var csrf = ReadCookie(setCookies, BrowserAuthOptions.CsrfCookie);
var cookieHeader = $"{BrowserAuthOptions.RefreshCookie}={refresh}; {BrowserAuthOptions.CsrfCookie}={csrf}";
var missingCsrf = await SendRefreshAsync(client, cookieHeader, "http://localhost", null);
var wrongOrigin = await SendRefreshAsync(client, cookieHeader, "https://attacker.example", csrf);
var accepted = await SendRefreshAsync(client, cookieHeader, "http://localhost", csrf);
Assert.Equal(HttpStatusCode.Forbidden, missingCsrf.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, wrongOrigin.StatusCode);
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
Assert.Contains(accepted.Headers.GetValues("Set-Cookie"), value =>
value.StartsWith(BrowserAuthOptions.RefreshCookie, StringComparison.Ordinal));
}
[Fact]
public async Task Access_cookie_is_ignored_without_same_origin_evidence()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
const string phone = "13890000002";
var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "Cookie User" }.WithTestPassword();
await factory.SeedAsync(
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Cookie Tenant" },
user,
new TenantMembership
{
TenantId = tenantId,
UserId = user.Id,
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = false
});
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
using var login = new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/browser/login/password")
{
Content = JsonContent.Create(new PasswordLoginDto
{
Realm = AuthRealm.Tenant,
TenantCode = tenantId.ToString("N"),
Identifier = phone,
Password = PasswordTestUserExtensions.TestPassword
})
};
login.Headers.Add("Origin", "http://localhost");
var loginResponse = await client.SendAsync(login);
var access = ReadCookie(loginResponse.Headers.GetValues("Set-Cookie").ToArray(),
BrowserAuthOptions.AccessCookie);
using var crossSite = new HttpRequestMessage(HttpMethod.Get, "/api/tenant/me");
crossSite.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}");
crossSite.Headers.Add("Sec-Fetch-Site", "cross-site");
var rejected = await client.SendAsync(crossSite);
using var sameOrigin = new HttpRequestMessage(HttpMethod.Get, "/api/tenant/me");
sameOrigin.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}");
sameOrigin.Headers.Add("Origin", "http://localhost");
var accepted = await client.SendAsync(sameOrigin);
Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode);
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
}
private static async Task<HttpResponseMessage> SendRefreshAsync(
HttpClient client,
string cookieHeader,
string origin,
string? csrf)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/browser/refresh");
request.Headers.Add("Cookie", cookieHeader);
request.Headers.Add("Origin", origin);
if (csrf is not null) request.Headers.Add(BrowserAuthOptions.CsrfHeader, csrf);
return await client.SendAsync(request);
}
private static string ReadCookie(IEnumerable<string> setCookies, string name)
{
var prefix = name + "=";
var header = setCookies.Single(value => value.StartsWith(prefix, StringComparison.Ordinal));
return header[prefix.Length..header.IndexOf(';')];
}
}