184 lines
7.1 KiB
C#
184 lines
7.1 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Net.Http.Json;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using Tiku.Api.Contracts;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Tenancy;
|
|
|
|
namespace Tiku.IntegrationTests.Api;
|
|
|
|
internal sealed record TestAuthenticationTokens(string AccessToken, string RefreshToken);
|
|
|
|
internal static class AuthenticationTestClientExtensions
|
|
{
|
|
private static readonly ConcurrentDictionary<string, string> AuthenticatorKeys = new(StringComparer.Ordinal);
|
|
|
|
public static async Task<TestAuthenticationTokens> LoginAsTenantAsync(
|
|
this HttpClient client,
|
|
Guid tenantId,
|
|
string identifier,
|
|
string password = PasswordTestUserExtensions.TestPassword)
|
|
{
|
|
SetTenantHeader(client, tenantId);
|
|
var response = await client.PostAsJsonAsync(
|
|
"/api/auth/login/password",
|
|
new PasswordLoginDto
|
|
{
|
|
Realm = AuthRealm.Tenant,
|
|
TenantCode = tenantId.ToString("N"),
|
|
Identifier = identifier,
|
|
Password = password
|
|
});
|
|
|
|
return await client.CompleteTenantAuthenticationAsync(response, tenantId, identifier);
|
|
}
|
|
|
|
public static async Task<TestAuthenticationTokens> CompleteTenantAuthenticationAsync(
|
|
this HttpClient client,
|
|
HttpResponseMessage response,
|
|
Guid tenantId,
|
|
string authenticatorCacheKey)
|
|
{
|
|
SetTenantHeader(client, tenantId);
|
|
using var authentication = await ReadSuccessfulJsonAsync(response);
|
|
var root = authentication.RootElement;
|
|
var status = root.GetProperty("status").GetString();
|
|
|
|
if (string.Equals(status, "authenticated", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return ReadTokens(root.GetProperty("user").GetProperty("tokens"));
|
|
}
|
|
|
|
var challengeToken = root.GetProperty("challengeToken").GetString()
|
|
?? throw new InvalidOperationException("Authentication challenge did not contain a challenge token.");
|
|
var keyId = $"{tenantId:N}:{authenticatorCacheKey}";
|
|
|
|
if (string.Equals(status, "mfa_enrollment_required", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var setupResponse = await client.PostAsJsonAsync(
|
|
"/api/auth/mfa/totp/setup",
|
|
new MfaChallengeDto { ChallengeToken = challengeToken });
|
|
using var setup = await ReadSuccessfulJsonAsync(setupResponse);
|
|
var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString()
|
|
?? throw new InvalidOperationException("MFA setup did not return a shared key.");
|
|
AuthenticatorKeys[keyId] = sharedKey;
|
|
|
|
var confirmResponse = await client.PostAsJsonAsync(
|
|
"/api/auth/mfa/totp/confirm",
|
|
new MfaChallengeDto
|
|
{
|
|
ChallengeToken = challengeToken,
|
|
Code = GenerateTotp(sharedKey)
|
|
});
|
|
using var confirmation = await ReadSuccessfulJsonAsync(confirmResponse);
|
|
return ReadTokens(
|
|
confirmation.RootElement
|
|
.GetProperty("authentication")
|
|
.GetProperty("user")
|
|
.GetProperty("tokens"));
|
|
}
|
|
|
|
if (string.Equals(status, "mfa_required", StringComparison.OrdinalIgnoreCase) &&
|
|
AuthenticatorKeys.TryGetValue(keyId, out var existingKey))
|
|
{
|
|
var verifyResponse = await client.PostAsJsonAsync(
|
|
"/api/auth/mfa/totp/verify",
|
|
new MfaChallengeDto
|
|
{
|
|
ChallengeToken = challengeToken,
|
|
Code = GenerateTotp(existingKey)
|
|
});
|
|
using var verification = await ReadSuccessfulJsonAsync(verifyResponse);
|
|
return ReadTokens(verification.RootElement.GetProperty("user").GetProperty("tokens"));
|
|
}
|
|
|
|
throw new InvalidOperationException($"Unsupported test authentication status '{status}'.");
|
|
}
|
|
|
|
public static void UseAccessToken(this HttpClient client, TestAuthenticationTokens tokens)
|
|
{
|
|
client.DefaultRequestHeaders.Authorization = new("Bearer", tokens.AccessToken);
|
|
}
|
|
|
|
private static void SetTenantHeader(HttpClient client, Guid tenantId)
|
|
{
|
|
client.DefaultRequestHeaders.Remove("x-tenant-code");
|
|
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
|
}
|
|
|
|
private static async Task<JsonDocument> ReadSuccessfulJsonAsync(HttpResponseMessage response)
|
|
{
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new HttpRequestException(
|
|
$"Authentication request failed with {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
|
}
|
|
|
|
return JsonDocument.Parse(body);
|
|
}
|
|
|
|
private static TestAuthenticationTokens ReadTokens(JsonElement tokens)
|
|
{
|
|
var accessToken = tokens.GetProperty("accessToken").GetString()
|
|
?? throw new InvalidOperationException("Authentication response did not contain an access token.");
|
|
var refreshToken = tokens.GetProperty("refreshToken").GetString()
|
|
?? throw new InvalidOperationException("Authentication response did not contain a refresh token.");
|
|
return new TestAuthenticationTokens(accessToken, refreshToken);
|
|
}
|
|
|
|
internal static string GenerateTotp(string sharedKey)
|
|
{
|
|
var secret = DecodeBase32(sharedKey);
|
|
var counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30;
|
|
Span<byte> counterBytes = stackalloc byte[8];
|
|
for (var index = counterBytes.Length - 1; index >= 0; index--)
|
|
{
|
|
counterBytes[index] = (byte)(counter & 0xff);
|
|
counter >>= 8;
|
|
}
|
|
|
|
var hash = HMACSHA1.HashData(secret, counterBytes);
|
|
var offset = hash[^1] & 0x0f;
|
|
var binaryCode = ((hash[offset] & 0x7f) << 24) |
|
|
(hash[offset + 1] << 16) |
|
|
(hash[offset + 2] << 8) |
|
|
hash[offset + 3];
|
|
return (binaryCode % 1_000_000).ToString("D6", System.Globalization.CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private static byte[] DecodeBase32(string value)
|
|
{
|
|
var normalized = value.Replace(" ", string.Empty, StringComparison.Ordinal)
|
|
.TrimEnd('=')
|
|
.ToUpperInvariant();
|
|
var output = new byte[normalized.Length * 5 / 8];
|
|
var buffer = 0;
|
|
var bitsInBuffer = 0;
|
|
var outputIndex = 0;
|
|
|
|
foreach (var character in normalized)
|
|
{
|
|
var digit = character switch
|
|
{
|
|
>= 'A' and <= 'Z' => character - 'A',
|
|
>= '2' and <= '7' => character - '2' + 26,
|
|
_ => throw new FormatException("Authenticator shared key is not valid Base32.")
|
|
};
|
|
buffer = (buffer << 5) | digit;
|
|
bitsInBuffer += 5;
|
|
if (bitsInBuffer < 8)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
output[outputIndex++] = (byte)(buffer >> (bitsInBuffer - 8));
|
|
bitsInBuffer -= 8;
|
|
buffer &= (1 << bitsInBuffer) - 1;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
}
|