forked from gongxuegit/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
183
Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs
Normal file
183
Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user