forked from xiongyuxing/tiku-backend.net
184 lines
8.4 KiB
C#
184 lines
8.4 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Tiku.Api.Contracts;
|
|
using Tiku.Api.Options;
|
|
using Tiku.Application.Auth;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Auth;
|
|
|
|
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 Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
|
{
|
|
HandleCookies = false
|
|
});
|
|
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
|
client.DefaultRequestHeaders.Add("Origin", "http://localhost");
|
|
|
|
var response = await client.PostAsJsonAsync("/api/browser-auth/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 Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
|
{
|
|
HandleCookies = false
|
|
});
|
|
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
|
using var login = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/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 Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
|
{
|
|
HandleCookies = false
|
|
});
|
|
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
|
using var login = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/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/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/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/browser-auth/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(';')];
|
|
}
|
|
}
|