feat(auth): replace TOTP with phone-first login

This commit is contained in:
2026-07-28 17:39:29 +08:00
parent e7d350ec3d
commit c7f9a4e3c9
43 changed files with 18386 additions and 882 deletions

View File

@@ -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)}"
})
}
};

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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);

View File

@@ -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"));
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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"));
}

View File

@@ -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()
{