forked from xiongyuxing/tiku-backend.net
feat(auth): replace TOTP with phone-first login
This commit is contained in:
@@ -142,13 +142,6 @@ public sealed class AuthEndpointTests
|
||||
new HttpRequestMessage(HttpMethod.Post, "/api/auth/logout")
|
||||
{
|
||||
Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken })
|
||||
},
|
||||
new HttpRequestMessage(HttpMethod.Post, "/api/auth/mfa/totp/setup")
|
||||
{
|
||||
Content = JsonContent.Create(new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = $"c1.p.-.{new string('b', 86)}"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Controllers;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthMfaLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Enrollment_returns_recovery_codes_once_then_subsequent_login_requires_mfa()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedBackendUserAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||
|
||||
using var login = await PostPasswordLoginAsync(client, seed);
|
||||
Assert.Equal("mfa_enrollment_required", login.RootElement.GetProperty("status").GetString());
|
||||
var challengeToken = login.RootElement.GetProperty("challengeToken").GetString()!;
|
||||
|
||||
var setupResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/setup",
|
||||
new MfaChallengeDto { ChallengeToken = challengeToken });
|
||||
setupResponse.EnsureSuccessStatusCode();
|
||||
using var setup = JsonDocument.Parse(await setupResponse.Content.ReadAsStringAsync());
|
||||
var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString()!;
|
||||
|
||||
var confirmRequest = new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = challengeToken,
|
||||
Code = AuthenticationTestClientExtensions.GenerateTotp(sharedKey)
|
||||
};
|
||||
var confirmResponse = await client.PostAsJsonAsync("/api/auth/mfa/totp/confirm", confirmRequest);
|
||||
confirmResponse.EnsureSuccessStatusCode();
|
||||
using var confirmation = JsonDocument.Parse(await confirmResponse.Content.ReadAsStringAsync());
|
||||
Assert.Equal(
|
||||
"authenticated",
|
||||
confirmation.RootElement.GetProperty("authentication").GetProperty("status").GetString());
|
||||
Assert.Equal(10, confirmation.RootElement.GetProperty("recoveryCodes").GetArrayLength());
|
||||
var recoveryCode = confirmation.RootElement.GetProperty("recoveryCodes")[0].GetString()!;
|
||||
|
||||
var replayResponse = await client.PostAsJsonAsync("/api/auth/mfa/totp/confirm", confirmRequest);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, replayResponse.StatusCode);
|
||||
|
||||
using var nextLogin = await PostPasswordLoginAsync(client, seed);
|
||||
Assert.Equal("mfa_required", nextLogin.RootElement.GetProperty("status").GetString());
|
||||
Assert.False(nextLogin.RootElement.TryGetProperty("recoveryCodes", out _));
|
||||
|
||||
var recoveryResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/verify",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = nextLogin.RootElement.GetProperty("challengeToken").GetString()!,
|
||||
Code = recoveryCode
|
||||
});
|
||||
recoveryResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var finalLogin = await PostPasswordLoginAsync(client, seed);
|
||||
var replayedRecoveryResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/verify",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = finalLogin.RootElement.GetProperty("challengeToken").GetString()!,
|
||||
Code = recoveryCode
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, replayedRecoveryResponse.StatusCode);
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify recovery code audit");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var recoveryAudits = await dbContext.AuditLogs
|
||||
.Where(item => item.Action == "auth.mfa.verified")
|
||||
.ToArrayAsync();
|
||||
var recoveryAudit = Assert.Single(recoveryAudits, item =>
|
||||
item.Details.ToString().Contains("recovery_code", StringComparison.Ordinal));
|
||||
Assert.Equal(seed.TenantId, recoveryAudit.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forced_password_change_precedes_mfa_enrollment()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedBackendUserAsync(factory, forcePasswordChange: true);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||
|
||||
using var login = await PostPasswordLoginAsync(client, seed);
|
||||
|
||||
Assert.Equal("password_change_required", login.RootElement.GetProperty("status").GetString());
|
||||
Assert.False(string.IsNullOrWhiteSpace(login.RootElement.GetProperty("challengeToken").GetString()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(AuthController.LoginWithPassword), "login/password")]
|
||||
[InlineData(nameof(AuthController.SendSmsCode), "sms/send")]
|
||||
[InlineData(nameof(AuthController.LoginWithSms), "login/sms")]
|
||||
[InlineData(nameof(AuthController.LoginWithWechatWeb), "oauth/wechat")]
|
||||
[InlineData(nameof(AuthController.LoginWithWechatMiniApp), "oauth/wechat-miniapp")]
|
||||
[InlineData(nameof(AuthController.SetupTotp), "mfa/totp/setup")]
|
||||
[InlineData(nameof(AuthController.ConfirmTotp), "mfa/totp/confirm")]
|
||||
[InlineData(nameof(AuthController.VerifyTotp), "mfa/totp/verify")]
|
||||
[InlineData(nameof(AuthController.Refresh), "refresh")]
|
||||
[InlineData(nameof(AuthController.Logout), "logout")]
|
||||
[InlineData(nameof(AuthController.LogoutAll), "logout-all")]
|
||||
public void Authentication_routes_match_the_v2_contract(string actionName, string route)
|
||||
{
|
||||
var action = typeof(AuthController).GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance);
|
||||
var attribute = action?.GetCustomAttribute<HttpPostAttribute>();
|
||||
|
||||
Assert.NotNull(attribute);
|
||||
Assert.Equal(route, attribute.Template);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> PostPasswordLoginAsync(
|
||||
HttpClient client,
|
||||
(Guid TenantId, string Phone) seed)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Identifier = seed.Phone,
|
||||
Password = PasswordTestUserExtensions.TestPassword
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
return JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, string Phone)> SeedBackendUserAsync(
|
||||
ApiTestFactory factory,
|
||||
bool forcePasswordChange = false)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
const string phone = "13800000000";
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "MFA Lifecycle Tenant"
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "MFA Lifecycle User",
|
||||
ForcePasswordChange = forcePasswordChange
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
return (tenantId, phone);
|
||||
}
|
||||
}
|
||||
122
Tiku.IntegrationTests/Api/AuthPasswordLifecycleTests.cs
Normal file
122
Tiku.IntegrationTests/Api/AuthPasswordLifecycleTests.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Controllers;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthPasswordLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Phone_and_password_login_authenticates_without_totp()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedUserAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||
|
||||
using var login = await PostPasswordLoginAsync(client, seed);
|
||||
|
||||
Assert.Equal("authenticated", login.RootElement.GetProperty("status").GetString());
|
||||
Assert.Equal(seed.Phone, login.RootElement.GetProperty("user").GetProperty("phone").GetString());
|
||||
Assert.False(login.RootElement.TryGetProperty("challengeToken", out var challenge) &&
|
||||
challenge.ValueKind == JsonValueKind.String);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forced_password_change_finishes_with_an_authenticated_session()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedUserAsync(factory, forcePasswordChange: true);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||
|
||||
using var login = await PostPasswordLoginAsync(client, seed);
|
||||
Assert.Equal("password_change_required", login.RootElement.GetProperty("status").GetString());
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/auth/password/change-required",
|
||||
new RequiredPasswordChangeDto
|
||||
{
|
||||
ChallengeToken = login.RootElement.GetProperty("challengeToken").GetString()!,
|
||||
NewPassword = "ChangedPassword2026"
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var changed = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal("authenticated", changed.RootElement.GetProperty("status").GetString());
|
||||
Assert.False(changed.RootElement.TryGetProperty("challengeToken", out var challenge) &&
|
||||
challenge.ValueKind == JsonValueKind.String);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(AuthController.LoginWithPassword), "login/password")]
|
||||
[InlineData(nameof(AuthController.SendSmsCode), "sms/send")]
|
||||
[InlineData(nameof(AuthController.LoginWithSms), "login/sms")]
|
||||
[InlineData(nameof(AuthController.LoginWithWechatWeb), "oauth/wechat")]
|
||||
[InlineData(nameof(AuthController.LoginWithWechatMiniApp), "oauth/wechat-miniapp")]
|
||||
[InlineData(nameof(AuthController.ChangeRequiredPassword), "password/change-required")]
|
||||
[InlineData(nameof(AuthController.Refresh), "refresh")]
|
||||
[InlineData(nameof(AuthController.Logout), "logout")]
|
||||
[InlineData(nameof(AuthController.LogoutAll), "logout-all")]
|
||||
public void Authentication_routes_match_the_v2_contract(string actionName, string route)
|
||||
{
|
||||
var action = typeof(AuthController).GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance);
|
||||
var attribute = action?.GetCustomAttribute<HttpPostAttribute>();
|
||||
|
||||
Assert.NotNull(attribute);
|
||||
Assert.Equal(route, attribute.Template);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> PostPasswordLoginAsync(
|
||||
HttpClient client,
|
||||
(Guid TenantId, string Phone) seed)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Identifier = seed.Phone,
|
||||
Password = PasswordTestUserExtensions.TestPassword
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
return JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, string Phone)> SeedUserAsync(
|
||||
ApiTestFactory factory,
|
||||
bool forcePasswordChange = false)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
const string phone = "13800000000";
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Password Lifecycle Tenant"
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Password Lifecycle User",
|
||||
ForcePasswordChange = forcePasswordChange
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
return (tenantId, phone);
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,7 @@ public sealed class AuthRateLimitPolicyTests
|
||||
[$"{AuthRateLimitOptions.SectionName}:PasswordPermitLimit"] = "7",
|
||||
[$"{AuthRateLimitOptions.SectionName}:PasswordWindowSeconds"] = "600",
|
||||
[$"{AuthRateLimitOptions.SectionName}:SmsPermitLimit"] = "3",
|
||||
[$"{AuthRateLimitOptions.SectionName}:SmsWindowSeconds"] = "90",
|
||||
[$"{AuthRateLimitOptions.SectionName}:MfaPermitLimit"] = "4",
|
||||
[$"{AuthRateLimitOptions.SectionName}:MfaWindowSeconds"] = "120"
|
||||
[$"{AuthRateLimitOptions.SectionName}:SmsWindowSeconds"] = "90"
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -37,8 +35,6 @@ public sealed class AuthRateLimitPolicyTests
|
||||
Assert.Equal(600, options.PasswordWindowSeconds);
|
||||
Assert.Equal(3, options.SmsPermitLimit);
|
||||
Assert.Equal(90, options.SmsWindowSeconds);
|
||||
Assert.Equal(4, options.MfaPermitLimit);
|
||||
Assert.Equal(120, options.MfaWindowSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -47,7 +43,7 @@ public sealed class AuthRateLimitPolicyTests
|
||||
var options = new AuthRateLimitOptions
|
||||
{
|
||||
PasswordPermitLimit = 0,
|
||||
MfaWindowSeconds = 0
|
||||
SmsWindowSeconds = 0
|
||||
};
|
||||
var validationResults = new List<ValidationResult>();
|
||||
|
||||
@@ -73,13 +69,10 @@ public sealed class AuthRateLimitPolicyTests
|
||||
AssertPolicy(nameof(AuthController.SendSmsCode), AuthRateLimitPolicies.Sms);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(AuthController.SetupTotp))]
|
||||
[InlineData(nameof(AuthController.ConfirmTotp))]
|
||||
[InlineData(nameof(AuthController.VerifyTotp))]
|
||||
public void Mfa_challenge_endpoints_use_the_mfa_named_policy(string methodName)
|
||||
[Fact]
|
||||
public void Required_password_change_uses_the_password_named_policy()
|
||||
{
|
||||
AssertPolicy(methodName, AuthRateLimitPolicies.Mfa);
|
||||
AssertPolicy(nameof(AuthController.ChangeRequiredPassword), AuthRateLimitPolicies.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -130,23 +123,6 @@ public sealed class AuthRateLimitPolicyTests
|
||||
Assert.DoesNotContain("13800000000", first, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mfa_partition_uses_the_challenge_token_and_resets_the_request_body()
|
||||
{
|
||||
const string body = """{"challengeToken":"challenge-one","code":"123456"}""";
|
||||
var first = await CapturePartitionAsync(
|
||||
AuthRateLimitPolicies.Mfa,
|
||||
body,
|
||||
"127.0.0.1");
|
||||
var second = await CapturePartitionAsync(
|
||||
AuthRateLimitPolicies.Mfa,
|
||||
"""{"challengeToken":"challenge-two","code":"123456"}""",
|
||||
"127.0.0.1");
|
||||
|
||||
Assert.NotEqual(first, second);
|
||||
Assert.DoesNotContain("challenge-one", first, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(string methodName, string expectedPolicy)
|
||||
{
|
||||
var method = typeof(AuthController).GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
@@ -150,7 +150,7 @@ public sealed class AuthSessionLifecycleTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_session_and_refresh_fail_immediately_after_the_last_permission_is_revoked()
|
||||
public async Task Tenant_session_remains_valid_after_a_backend_permission_is_revoked()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedActiveMemberAsync(factory);
|
||||
@@ -182,7 +182,7 @@ public sealed class AuthSessionLifecycleTests
|
||||
UserId = seed.UserId,
|
||||
RoleId = role.Id
|
||||
});
|
||||
var tokens = await IssueAsync(factory, seed, mfaSatisfied: true);
|
||||
var tokens = await IssueAsync(factory, seed);
|
||||
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Revoke final backend permission"))
|
||||
@@ -194,21 +194,12 @@ public sealed class AuthSessionLifecycleTests
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Validate revoked backend session"))
|
||||
using (var scope = factory.CreateSystemScope("Validate tenant session"))
|
||||
{
|
||||
var store = scope.ServiceProvider.GetRequiredService<IAuthSessionStore>();
|
||||
Assert.Null(await store.ValidateAccessSessionAsync(
|
||||
Assert.NotNull(await store.ValidateAccessSessionAsync(
|
||||
locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
|
||||
await Assert.ThrowsAsync<SessionRevokedException>(() =>
|
||||
store.RotateAsync(tokens.RefreshToken, null, null));
|
||||
}
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Verify revoked backend family"))
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var session = await dbContext.AuthSessions.SingleAsync(item => item.Id == locator.SessionId);
|
||||
Assert.NotNull(session.RevokedAt);
|
||||
Assert.Equal("realm_access_revoked", session.RevokedReason);
|
||||
Assert.NotNull(await store.RotateAsync(tokens.RefreshToken, null, null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,8 +227,7 @@ public sealed class AuthSessionLifecycleTests
|
||||
|
||||
private static async Task<AuthTokenPair> IssueAsync(
|
||||
ApiTestFactory factory,
|
||||
SessionSeed seed,
|
||||
bool mfaSatisfied = false)
|
||||
SessionSeed seed)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope("Issue authentication session");
|
||||
return await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>().IssueAsync(
|
||||
@@ -249,7 +239,6 @@ public sealed class AuthSessionLifecycleTests
|
||||
AuthRealm.Tenant,
|
||||
seed.TenantId,
|
||||
"integration-test",
|
||||
mfaSatisfied,
|
||||
"127.0.0.1",
|
||||
"integration-test"));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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;
|
||||
@@ -12,8 +10,6 @@ internal sealed record TestAuthenticationTokens(string AccessToken, string Refre
|
||||
|
||||
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,
|
||||
@@ -56,123 +52,31 @@ internal static class AuthenticationTestClientExtensions
|
||||
this HttpClient client,
|
||||
HttpResponseMessage response,
|
||||
Guid tenantId,
|
||||
string authenticatorCacheKey)
|
||||
string _)
|
||||
{
|
||||
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}'.");
|
||||
return string.Equals(status, "authenticated", StringComparison.OrdinalIgnoreCase)
|
||||
? ReadTokens(root.GetProperty("user").GetProperty("tokens"))
|
||||
: throw new InvalidOperationException($"Unsupported test authentication status '{status}'.");
|
||||
}
|
||||
|
||||
private static async Task<TestAuthenticationTokens> CompletePlatformAuthenticationAsync(
|
||||
this HttpClient client,
|
||||
HttpResponseMessage response,
|
||||
string authenticatorCacheKey)
|
||||
string _)
|
||||
{
|
||||
client.DefaultRequestHeaders.Remove("x-tenant-code");
|
||||
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 = $"platform:{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}'.");
|
||||
return string.Equals(status, "authenticated", StringComparison.OrdinalIgnoreCase)
|
||||
? ReadTokens(root.GetProperty("user").GetProperty("tokens"))
|
||||
: throw new InvalidOperationException($"Unsupported test authentication status '{status}'.");
|
||||
}
|
||||
|
||||
public static void UseAccessToken(this HttpClient client, TestAuthenticationTokens tokens)
|
||||
@@ -207,56 +111,4 @@ internal static class AuthenticationTestClientExtensions
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,6 @@ public sealed class PlatformAdminStaticEndpointTests
|
||||
Assert.Contains("fallbackToMock: false", runtime, StringComparison.Ordinal);
|
||||
Assert.Contains("/api/auth/login/password", authentication, StringComparison.Ordinal);
|
||||
Assert.Contains("/api/auth/password/change-required", authentication, StringComparison.Ordinal);
|
||||
Assert.Contains("/api/auth/mfa/totp/confirm", authentication, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("/api/auth/mfa", authentication, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace Tiku.IntegrationTests.Api;
|
||||
public sealed class RbacAuthorizationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TenantPolicy_RequiresCurrentMembershipPermissionAndMfa()
|
||||
public async Task TenantPolicy_requires_current_membership_and_permission()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
@@ -21,20 +21,15 @@ public sealed class RbacAuthorizationTests
|
||||
var authorization = provider.GetRequiredService<IAuthorizationService>();
|
||||
|
||||
var allowed = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: true),
|
||||
null,
|
||||
BackendPermissions.TenantRoleManage);
|
||||
var missingMfa = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: false),
|
||||
Principal(userId, "tenant", tenantId),
|
||||
null,
|
||||
BackendPermissions.TenantRoleManage);
|
||||
var wrongTenant = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", Guid.NewGuid(), hasMfa: true),
|
||||
Principal(userId, "tenant", Guid.NewGuid()),
|
||||
null,
|
||||
BackendPermissions.TenantRoleManage);
|
||||
|
||||
Assert.True(allowed.Succeeded);
|
||||
Assert.False(missingMfa.Succeeded);
|
||||
Assert.False(wrongTenant.Succeeded);
|
||||
}
|
||||
|
||||
@@ -51,11 +46,11 @@ public sealed class RbacAuthorizationTests
|
||||
var authorization = provider.GetRequiredService<IAuthorizationService>();
|
||||
|
||||
var tenantRealm = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: true),
|
||||
Principal(userId, "tenant", tenantId),
|
||||
null,
|
||||
BackendPermissions.PlatformRoleManage);
|
||||
var platformRealm = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "platform", null, hasMfa: true),
|
||||
Principal(userId, "platform", null),
|
||||
null,
|
||||
BackendPermissions.PlatformRoleManage);
|
||||
|
||||
@@ -71,7 +66,7 @@ public sealed class RbacAuthorizationTests
|
||||
var snapshot = Snapshot(userId, tenantId);
|
||||
await using var provider = Services(snapshot);
|
||||
var authorization = provider.GetRequiredService<IAuthorizationService>();
|
||||
var principal = Principal(userId, "tenant", tenantId, hasMfa: true);
|
||||
var principal = Principal(userId, "tenant", tenantId);
|
||||
((ClaimsIdentity)principal.Identity!).AddClaim(new Claim(ClaimTypes.Role, "TenantOwner"));
|
||||
|
||||
var result = await authorization.AuthorizeAsync(
|
||||
@@ -87,7 +82,7 @@ public sealed class RbacAuthorizationTests
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var principal = Principal(userId, "tenant", tenantId, hasMfa: true);
|
||||
var principal = Principal(userId, "tenant", tenantId);
|
||||
var selfSnapshot = Snapshot(
|
||||
userId,
|
||||
tenantId,
|
||||
@@ -128,7 +123,7 @@ public sealed class RbacAuthorizationTests
|
||||
true);
|
||||
await using var provider = Services(Snapshot(userId, tenantId, dataScope: scope));
|
||||
var authorization = provider.GetRequiredService<IAuthorizationService>();
|
||||
var principal = Principal(userId, "tenant", tenantId, hasMfa: true);
|
||||
var principal = Principal(userId, "tenant", tenantId);
|
||||
var requirement = new TenantResourceAccessRequirement();
|
||||
|
||||
var own = await authorization.AuthorizeAsync(
|
||||
@@ -168,7 +163,7 @@ public sealed class RbacAuthorizationTests
|
||||
await using var tenantProvider = Services(tenantSnapshot);
|
||||
var tenantAuthorization = tenantProvider.GetRequiredService<IAuthorizationService>();
|
||||
var tenantAllowed = await tenantAuthorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: true),
|
||||
Principal(userId, "tenant", tenantId),
|
||||
null,
|
||||
TikuPolicies.TenantBackofficeBootstrap);
|
||||
|
||||
@@ -179,11 +174,11 @@ public sealed class RbacAuthorizationTests
|
||||
await using var platformProvider = Services(platformSnapshot);
|
||||
var platformAuthorization = platformProvider.GetRequiredService<IAuthorizationService>();
|
||||
var platformAllowed = await platformAuthorization.AuthorizeAsync(
|
||||
Principal(userId, "platform", null, hasMfa: true),
|
||||
Principal(userId, "platform", null),
|
||||
null,
|
||||
TikuPolicies.PlatformBackofficeBootstrap);
|
||||
var tenantRealmDenied = await platformAuthorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: true),
|
||||
Principal(userId, "tenant", tenantId),
|
||||
null,
|
||||
TikuPolicies.PlatformBackofficeBootstrap);
|
||||
|
||||
@@ -204,8 +199,7 @@ public sealed class RbacAuthorizationTests
|
||||
private static ClaimsPrincipal Principal(
|
||||
Guid userId,
|
||||
string realm,
|
||||
Guid? tenantId,
|
||||
bool hasMfa)
|
||||
Guid? tenantId)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
@@ -217,11 +211,6 @@ public sealed class RbacAuthorizationTests
|
||||
claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString()));
|
||||
}
|
||||
|
||||
if (hasMfa)
|
||||
{
|
||||
claims.Add(new Claim(TikuClaimTypes.Mfa, "totp"));
|
||||
}
|
||||
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
}
|
||||
|
||||
|
||||
@@ -94,29 +94,6 @@ public sealed class SecurityFoundationTests
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Jwt_mfa_claim_must_match_the_database_session()
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
var userId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
client.DefaultRequestHeaders.Authorization = new(
|
||||
"Bearer",
|
||||
TestJwtKeys.CreateToken([
|
||||
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString()),
|
||||
new Claim(TikuClaimTypes.Mfa, "mfa")
|
||||
]));
|
||||
|
||||
var response = await client.GetAsync("/api/_security/authenticated");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Jwt_with_unknown_kid_is_rejected()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user