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

@@ -46,7 +46,7 @@ docs # ADR、架构说明和迁移路线
GET /platform-admin/
```
它是功能原型壳,不是正式视觉规范。当前默认连接同源真实 API并提供平台登录首次改密和 TOTP MFA 流程token 只保存在当前浏览器标签的 `sessionStorage`。真实模式不会回退显示 Mock 数据,目前开放概览、租户、员工和审计这组已经落地后端契约的页面,账务、公共题库等页面随对应 API 实现逐步开放。
它是功能原型壳,不是正式视觉规范。当前默认连接同源真实 API并提供平台账号密码登录首次改密流程token 只保存在当前浏览器标签的 `sessionStorage`。真实模式不会回退显示 Mock 数据,目前开放概览、租户、员工和审计这组已经落地后端契约的页面,账务、公共题库等页面随对应 API 实现逐步开放。
本地首次启动先创建 `tiku` 数据库并执行迁移:
@@ -63,7 +63,7 @@ Development 首次迁移会通过 EF Core 官方推荐的 `UseSeeding` / `UseAsy
初始密码:由 Tiku.DbMigrator 安全随机生成,仅在首次初始化的终端输出一次
```
首次登录必须立即修改初始密码并绑定 TOTP MFA。如果丢失首次输出的临时密码,应删除尚无业务数据的本地开发库后重新初始化,不要把密码补写到源码、`appsettings*.json` 或 README。Production 不会自动创建默认管理员,必须使用下文的显式安全引导命令。
首次登录必须立即修改初始密码;正式密码至少 8 位,并同时包含字母和数字。如果丢失首次输出的临时密码,应删除尚无业务数据的本地开发库后重新初始化,不要把密码补写到源码、`appsettings*.json` 或 README。Production 不会自动创建默认管理员,必须使用下文的显式安全引导命令。
开发环境只隐藏 EF Core 成功 SQL 日志ORM 警告与错误仍会输出。

View File

@@ -167,11 +167,9 @@ internal static class AuthenticationExtensions
realm.Value,
tenantId,
context.HttpContext.RequestAborted);
var tokenMfaSatisfied = principal.FindAll(TikuClaimTypes.Mfa)
.Any(claim => string.Equals(claim.Value, "mfa", StringComparison.Ordinal));
if (session is null || session.MfaSatisfied != tokenMfaSatisfied)
if (session is null)
{
context.Fail("Session, identity, membership, tenant, role or MFA state is no longer valid.");
context.Fail("Session, identity, membership, tenant or role state is no longer valid.");
}
}

View File

@@ -67,14 +67,6 @@ internal static class RateLimitingExtensions
authRateLimitOptions.SmsPermitLimit,
0,
authRateLimitOptions.SmsWindowSeconds)));
options.AddPolicy(
AuthRateLimitPolicies.Mfa,
httpContext => RateLimitPartition.GetFixedWindowLimiter(
AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Mfa),
_ => CreateLimiterOptions(
authRateLimitOptions.MfaPermitLimit,
0,
authRateLimitOptions.MfaWindowSeconds)));
options.OnRejected = WriteRateLimitProblemAsync;
});

View File

@@ -34,7 +34,7 @@ public sealed class PasswordLoginDto
/// 用户密码。
/// </summary>
[Required]
[StringLength(128, MinimumLength = 10)]
[StringLength(128, MinimumLength = 8)]
[Description("用户密码。")]
public string Password { get; set; } = string.Empty;
}
@@ -204,22 +204,6 @@ public sealed class AuthenticationResultDto
};
}
public sealed class MfaChallengeDto
{
[Required]
[StringLength(2048)]
public string ChallengeToken { get; set; } = string.Empty;
[StringLength(64)]
public string? Code { get; set; }
}
public sealed class MfaConfirmDto
{
public AuthenticationResultDto Authentication { get; init; } = default!;
public IReadOnlyList<string> RecoveryCodes { get; init; } = [];
}
public sealed class RequiredPasswordChangeDto
{
[Required]
@@ -227,6 +211,6 @@ public sealed class RequiredPasswordChangeDto
public string ChallengeToken { get; set; } = string.Empty;
[Required]
[StringLength(128, MinimumLength = 10)]
[StringLength(128, MinimumLength = 8)]
public string NewPassword { get; set; } = string.Empty;
}

View File

@@ -207,58 +207,9 @@ public sealed class AuthController(
return NoContent();
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
[HttpPost("mfa/totp/setup")]
public async Task<ActionResult<MfaSetupResult>> SetupTotp(
[FromBody] MfaChallengeDto request,
CancellationToken cancellationToken)
{
ResolveAuthChallengeTenant(request.ChallengeToken);
var result = await authService.SetupTotpAsync(
new MfaChallengeRequest(
request.ChallengeToken, null, GetIpAddress(), Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(result);
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
[HttpPost("mfa/totp/confirm")]
public async Task<ActionResult<MfaConfirmDto>> ConfirmTotp(
[FromBody] MfaChallengeDto request,
CancellationToken cancellationToken)
{
ResolveAuthChallengeTenant(request.ChallengeToken);
var result = await authService.ConfirmTotpAsync(
new MfaChallengeRequest(
request.ChallengeToken, request.Code, GetIpAddress(), Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(new MfaConfirmDto
{
Authentication = AuthenticationResultDto.FromApplication(result.Authentication),
RecoveryCodes = result.RecoveryCodes
});
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
[HttpPost("mfa/totp/verify")]
public async Task<ActionResult<AuthenticationResultDto>> VerifyTotp(
[FromBody] MfaChallengeDto request,
CancellationToken cancellationToken)
{
ResolveAuthChallengeTenant(request.ChallengeToken);
var result = await authService.VerifyTotpAsync(
new MfaChallengeRequest(
request.ChallengeToken, request.Code, GetIpAddress(), Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
[HttpPost("password/change-required")]
[EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
public async Task<ActionResult<AuthenticationResultDto>> ChangeRequiredPassword(
[FromBody] RequiredPasswordChangeDto request,
CancellationToken cancellationToken)

View File

@@ -18,7 +18,6 @@ public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next)
{
AuthRateLimitPolicies.Password => "identifier",
AuthRateLimitPolicies.Sms => "phone",
AuthRateLimitPolicies.Mfa => "challengeToken",
_ => null
};

View File

@@ -18,16 +18,10 @@ public sealed class AuthRateLimitOptions
[Range(1, 86_400)]
public int SmsWindowSeconds { get; set; } = 300;
[Range(1, 100)]
public int MfaPermitLimit { get; set; } = 5;
[Range(1, 86_400)]
public int MfaWindowSeconds { get; set; } = 300;
}
public static class AuthRateLimitPolicies
{
public const string Password = "auth-password";
public const string Sms = "auth-sms";
public const string Mfa = "auth-mfa";
}

View File

@@ -31,8 +31,6 @@ public sealed record PlatformPermissionRequirement : IAuthorizationRequirement
public string PermissionCode { get; }
}
public sealed record MfaRequirement : IAuthorizationRequirement;
public sealed record AllDataScopeRequirement : IAuthorizationRequirement;
public sealed record TenantResourceAccessRequirement : IAuthorizationRequirement;
@@ -159,22 +157,6 @@ internal sealed class PlatformPermissionAuthorizationHandler(ICurrentAccessConte
}
}
internal sealed class MfaAuthorizationHandler : AuthorizationHandler<MfaRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MfaRequirement requirement)
{
if (context.User.FindAll(TikuClaimTypes.Mfa).Any(claim =>
string.Equals(claim.Value, "mfa", StringComparison.OrdinalIgnoreCase) ||
string.Equals(claim.Value, "totp", StringComparison.OrdinalIgnoreCase) ||
string.Equals(claim.Value, bool.TrueString, StringComparison.OrdinalIgnoreCase)))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
internal sealed class AllDataScopeAuthorizationHandler(ICurrentAccessContext accessContext) :
AuthorizationHandler<AllDataScopeRequirement>
{
@@ -198,7 +180,6 @@ public static class AccessAuthorizationServiceCollectionExtensions
services.AddScoped<IAuthorizationHandler, CurrentPlatformAccessAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, TenantPermissionAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, PlatformPermissionAuthorizationHandler>();
services.AddSingleton<IAuthorizationHandler, MfaAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, AllDataScopeAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, TenantResourceAccessAuthorizationHandler>();
@@ -209,25 +190,16 @@ public static class AccessAuthorizationServiceCollectionExtensions
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new CurrentTenantMemberRequirement()));
options.AddPolicy(
TikuPolicies.Mfa,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new MfaRequirement()));
options.AddPolicy(
TikuPolicies.TenantBackofficeBootstrap,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new MfaRequirement()));
.AddRequirements(new CurrentTenantMemberRequirement()));
options.AddPolicy(
TikuPolicies.PlatformBackofficeBootstrap,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentPlatformAccessRequirement(),
new MfaRequirement()));
.AddRequirements(new CurrentPlatformAccessRequirement()));
// Temporary compatibility for controllers that have not yet been
// split into their module-specific permission policy. This must
// remain database-backed; an authenticated-only alias would reopen
@@ -238,8 +210,7 @@ public static class AccessAuthorizationServiceCollectionExtensions
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new TenantPermissionRequirement(BackendPermissions.TenantRoleManage),
new MfaRequirement()));
new TenantPermissionRequirement(BackendPermissions.TenantRoleManage)));
options.AddPolicy(
TikuPolicies.TenantContentManageAllScope,
policy => policy
@@ -247,8 +218,7 @@ public static class AccessAuthorizationServiceCollectionExtensions
.AddRequirements(
new CurrentTenantMemberRequirement(),
new TenantPermissionRequirement(BackendPermissions.TenantContentManage),
new AllDataScopeRequirement(),
new MfaRequirement()));
new AllDataScopeRequirement()));
options.AddPolicy(
TikuPolicies.TenantCommerceOperateAllScope,
policy => policy
@@ -256,8 +226,7 @@ public static class AccessAuthorizationServiceCollectionExtensions
.AddRequirements(
new CurrentTenantMemberRequirement(),
new TenantPermissionRequirement(BackendPermissions.TenantCommerceOperate),
new AllDataScopeRequirement(),
new MfaRequirement()));
new AllDataScopeRequirement()));
foreach (var permissionCode in BackendPermissions.Tenant)
{
@@ -267,8 +236,7 @@ public static class AccessAuthorizationServiceCollectionExtensions
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new TenantPermissionRequirement(permissionCode),
new MfaRequirement()));
new TenantPermissionRequirement(permissionCode)));
}
foreach (var permissionCode in BackendPermissions.Platform)
@@ -277,9 +245,7 @@ public static class AccessAuthorizationServiceCollectionExtensions
permissionCode,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new PlatformPermissionRequirement(permissionCode),
new MfaRequirement()));
.AddRequirements(new PlatformPermissionRequirement(permissionCode)));
}
});

View File

@@ -52,9 +52,7 @@
"PasswordPermitLimit": 5,
"PasswordWindowSeconds": 900,
"SmsPermitLimit": 5,
"SmsWindowSeconds": 300,
"MfaPermitLimit": 5,
"MfaWindowSeconds": 300
"SmsWindowSeconds": 300
}
},
"Authentication": {

View File

@@ -29,12 +29,6 @@
function gate() { return document.querySelector('#platformAuthGate'); }
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, character => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;',
})[character]);
}
function shell(title, description, content) {
gate().innerHTML = `<section class="platform-auth-card" role="dialog" aria-modal="true"><header><img src="./assets/logo.png" alt="" /><div><h1>${title}</h1><p>${description}</p></div></header>${content}<p class="platform-auth-error" id="platformAuthError" role="alert"></p></section>`;
gate().hidden = false;
@@ -68,13 +62,8 @@
pendingResolve = null;
}
function finishAuthentication(result, recoveryCodes = []) {
function finishAuthentication(result) {
storeAuthenticated(result);
if (recoveryCodes.length) {
shell('保存恢复代码', '这些代码只显示一次,请保存到安全位置。', `<div class="platform-auth-recovery">${recoveryCodes.map(code => `<div>${escapeHtml(code)}</div>`).join('')}</div><p class="auth-help">保存后再进入平台控制台。</p><button type="button" id="platformAuthContinue">我已保存,进入控制台</button>`);
document.querySelector('#platformAuthContinue').addEventListener('click', completeGate);
return;
}
completeGate();
}
@@ -82,13 +71,11 @@
challengeToken = result.challengeToken || '';
if (result.status === 'authenticated') { finishAuthentication(result); return; }
if (result.status === 'password_change_required') { renderPasswordChange(); return; }
if (result.status === 'mfa_enrollment_required') { await renderMfaEnrollment(); return; }
if (result.status === 'mfa_required') { renderMfaVerification(); return; }
throw new Error(`不支持的认证状态:${result.status || 'unknown'}`);
}
function renderLogin() {
shell('平台管理员登录', '连接真实 PostgreSQL 与 ASP.NET Core API。', `<form id="platformLoginForm"><label>平台账号<input name="identifier" type="email" autocomplete="username" required /></label><label>密码<input name="password" type="password" autocomplete="current-password" minlength="10" required /></label><button type="submit">登录</button></form>`);
shell('平台管理员登录', '连接真实 PostgreSQL 与 ASP.NET Core API。', `<form id="platformLoginForm"><label>平台账号<input name="identifier" type="email" autocomplete="username" required /></label><label>密码<input name="password" type="password" autocomplete="current-password" minlength="8" required /></label><button type="submit">登录</button></form>`);
document.querySelector('#platformLoginForm').addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
@@ -101,7 +88,7 @@
}
function renderPasswordChange() {
shell('设置正式密码', '首次登录必须先替换临时密码。', `<form id="platformPasswordForm"><label>新密码<input name="newPassword" type="password" autocomplete="new-password" minlength="10" required /></label><label>确认新密码<input name="confirmPassword" type="password" autocomplete="new-password" minlength="10" required /></label><button type="submit">更新密码并继续</button></form>`);
shell('设置正式密码', '至少 8 位,且必须同时包含字母和数字。', `<form id="platformPasswordForm"><label>新密码<input name="newPassword" type="password" autocomplete="new-password" minlength="8" required /></label><label>确认新密码<input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required /></label><button type="submit">更新密码并继续</button></form>`);
document.querySelector('#platformPasswordForm').addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
@@ -112,31 +99,6 @@
});
}
async function renderMfaEnrollment() {
const setup = await post('/api/auth/mfa/totp/setup', { challengeToken });
shell('绑定双重验证', '在认证器中添加密钥,然后输入当前 6 位验证码。', `<code>${escapeHtml(setup.sharedKey)}</code><p class="auth-help">也可在支持的认证器中导入:${escapeHtml(setup.authenticatorUri)}</p><form id="platformMfaForm"><label>动态验证码<input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required /></label><button type="submit">确认绑定</button></form>`);
document.querySelector('#platformMfaForm').addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
setBusy(form, true);
try {
const result = await post('/api/auth/mfa/totp/confirm', { challengeToken, code: form.elements.code.value.trim() });
finishAuthentication(result.authentication, result.recoveryCodes || []);
} catch (error) { showError(error); setBusy(form, false); }
});
}
function renderMfaVerification() {
shell('双重验证', '输入认证器中的当前 6 位验证码。', `<form id="platformMfaForm"><label>动态验证码<input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required autofocus /></label><button type="submit">验证并登录</button></form>`);
document.querySelector('#platformMfaForm').addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
setBusy(form, true);
try { await handleAuthenticationResult(await post('/api/auth/mfa/totp/verify', { challengeToken, code: form.elements.code.value.trim() })); }
catch (error) { showError(error); setBusy(form, false); }
});
}
function clearSession() {
[ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, ACCESS_EXPIRES_KEY, USER_KEY].forEach(key => sessionStorage.removeItem(key));
}

View File

@@ -46,5 +46,4 @@
.platform-auth-card input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
.platform-auth-card button { width: 100%; min-height: 42px; margin-top: 14px; border: 0; border-radius: 9px; background: #2563eb; color: white; font: inherit; font-weight: 700; cursor: pointer; }
.platform-auth-card button:disabled { opacity: .55; cursor: wait; }
.platform-auth-card code, .platform-auth-recovery { display: block; margin-top: 8px; padding: 12px; border-radius: 8px; background: #020617; color: #93c5fd; overflow-wrap: anywhere; }
.platform-auth-error { min-height: 20px; margin: 14px 0 0; color: #fca5a5; font-size: 13px; }

View File

@@ -52,10 +52,6 @@ public enum AuthenticationStatus
{
[JsonStringEnumMemberName("authenticated")]
Authenticated,
[JsonStringEnumMemberName("mfa_required")]
MfaRequired,
[JsonStringEnumMemberName("mfa_enrollment_required")]
MfaEnrollmentRequired,
[JsonStringEnumMemberName("password_change_required")]
PasswordChangeRequired
}
@@ -97,24 +93,12 @@ public sealed record RefreshSessionRequest(
public sealed record LogoutSessionRequest(
string RefreshToken);
public sealed record MfaChallengeRequest(
string ChallengeToken,
string? Code,
string? IpAddress,
string? UserAgent);
public sealed record PasswordChangeChallengeRequest(
string ChallengeToken,
string NewPassword,
string? IpAddress,
string? UserAgent);
public sealed record MfaSetupResult(string SharedKey, string AuthenticatorUri);
public sealed record MfaConfirmResult(
AuthenticationResult Authentication,
IReadOnlyList<string> RecoveryCodes);
public sealed record SmsSendResult(
Guid VerificationId,
DateTimeOffset ExpiresAt);

View File

@@ -28,18 +28,6 @@ public interface IAuthService
Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default);
Task<MfaSetupResult> SetupTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default);
Task<MfaConfirmResult> ConfirmTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default);
Task<AuthenticationResult> VerifyTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default);
Task<AuthenticationResult> ChangeRequiredPasswordAsync(
PasswordChangeChallengeRequest request,
CancellationToken cancellationToken = default);

View File

@@ -38,12 +38,11 @@ public sealed record AuthSessionIssueRequest(
AuthRealm Realm,
Guid? TenantId,
string Provider,
bool MfaSatisfied,
string? IpAddress,
string? UserAgent,
Guid? TokenFamilyId = null,
Guid? ParentSessionId = null);
public sealed record AuthSessionValidationResult(Guid UserId, AuthRealm Realm, Guid? TenantId, bool MfaSatisfied);
public sealed record AuthSessionValidationResult(Guid UserId, AuthRealm Realm, Guid? TenantId);
public readonly record struct RefreshTokenLocator(AuthRealm Realm, Guid? TenantId, Guid SessionId);

View File

@@ -10,6 +10,5 @@ public interface ITokenService
string? phone,
string? email,
AuthRealm realm,
Guid? tenantId,
bool mfaSatisfied);
Guid? tenantId);
}

View File

@@ -8,7 +8,6 @@ public static class TikuClaimTypes
public const string TenantId = "tid";
public const string SessionId = "sid";
public const string Realm = "scope";
public const string Mfa = "amr";
public const string Phone = ClaimTypes.MobilePhone;
public const string Email = ClaimTypes.Email;
}

View File

@@ -6,7 +6,6 @@ public static class TikuPolicies
public const string CurrentTenantMember = "current_tenant_member";
public const string TenantBackofficeBootstrap = "tenant_backoffice_bootstrap";
public const string PlatformBackofficeBootstrap = "platform_backoffice_bootstrap";
public const string Mfa = "mfa";
public const string TenantContentManageAllScope = "tenant:content:manage:all_scope";
public const string TenantCommerceOperateAllScope = "tenant:commerce:operate:all_scope";

View File

@@ -54,7 +54,7 @@ if (bootstrapOptions is not null)
{
var bootstrapper = ActivatorUtilities.CreateInstance<PlatformAdminBootstrapper>(scope.ServiceProvider);
var result = await bootstrapper.BootstrapAsync(bootstrapOptions);
Console.WriteLine($"Platform administrator '{result.Email}' was created and must change the temporary password and enroll MFA at first sign-in.");
Console.WriteLine($"Platform administrator '{result.Email}' was created and must change the temporary password at first sign-in.");
}
static string RequiredBootstrapSetting(IConfiguration configuration, string key)

View File

@@ -71,7 +71,6 @@ public sealed class AuthSession : AuditableEntity
public Guid? ReplacedBySessionId { get; set; }
public string TokenHash { get; set; } = string.Empty;
public string SecurityStamp { get; set; } = string.Empty;
public bool MfaSatisfied { get; set; }
public string Provider { get; set; } = string.Empty;
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
@@ -99,7 +98,7 @@ public sealed class AuthChallenge : Entity
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public enum AuthChallengePurpose { MfaEnrollment, MfaVerification, PasswordChange }
public enum AuthChallengePurpose { PasswordChange }
public sealed class SmsSendRateLimit : ITenantOwned
{

View File

@@ -192,99 +192,6 @@ public sealed class AuthService(
await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken);
}
public async Task<MfaSetupResult> SetupTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.MfaEnrollment, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
var reset = await userManager.ResetAuthenticatorKeyAsync(user);
if (!reset.Succeeded)
{
throw new InvalidOperationException("Unable to initialize the authenticator key.");
}
var key = await userManager.GetAuthenticatorKeyAsync(user)
?? throw new InvalidOperationException("Authenticator key was not generated.");
challenge.SecurityStamp = user.SecurityStamp ?? string.Empty;
var account = user.Email ?? user.Phone ?? user.Id.ToString();
var uri = $"otpauth://totp/{Uri.EscapeDataString("TIKU:" + account)}" +
$"?secret={Uri.EscapeDataString(key)}&issuer={Uri.EscapeDataString("TIKU")}&digits=6";
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.enrollment_setup", null,
request.IpAddress, request.UserAgent, cancellationToken);
return new MfaSetupResult(key, uri);
}
public async Task<MfaConfirmResult> ConfirmTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.MfaEnrollment, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
if (string.IsNullOrWhiteSpace(request.Code) ||
!await userManager.VerifyTwoFactorTokenAsync(
user, TokenOptions.DefaultAuthenticatorProvider, NormalizeTotp(request.Code)))
{
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.enrollment_denied", "invalid_code",
request.IpAddress, request.UserAgent, cancellationToken);
throw new InvalidCredentialsException("invalid_mfa_code");
}
var enabled = await userManager.SetTwoFactorEnabledAsync(user, true);
if (!enabled.Succeeded)
{
throw new InvalidOperationException("Unable to enable two-factor authentication.");
}
await ConsumeChallengeAsync(challenge, cancellationToken);
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.enrollment_confirmed", null,
request.IpAddress, request.UserAgent, cancellationToken);
var recoveryCodes = (await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10))?.ToArray() ?? [];
var authentication = await IssueFromChallengeAsync(
challenge, user, request.IpAddress, request.UserAgent, cancellationToken);
return new MfaConfirmResult(authentication, recoveryCodes);
}
public async Task<AuthenticationResult> VerifyTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.MfaVerification, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
var recoveryCode = request.Code?.Trim();
var totpCode = NormalizeTotp(request.Code);
var verifiedByTotp = !string.IsNullOrWhiteSpace(totpCode) &&
await userManager.VerifyTwoFactorTokenAsync(
user, TokenOptions.DefaultAuthenticatorProvider, totpCode);
var verifiedByRecoveryCode = !verifiedByTotp &&
!string.IsNullOrWhiteSpace(recoveryCode) &&
(await userManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode)).Succeeded;
if (!verifiedByTotp && !verifiedByRecoveryCode)
{
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.verification_denied", "invalid_code",
request.IpAddress, request.UserAgent, cancellationToken);
throw new InvalidCredentialsException("invalid_mfa_code");
}
await ConsumeChallengeAsync(challenge, cancellationToken);
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.verified",
verifiedByRecoveryCode ? "recovery_code" : "totp",
request.IpAddress, request.UserAgent, cancellationToken);
return await IssueFromChallengeAsync(
challenge, user, request.IpAddress, request.UserAgent, cancellationToken);
}
public async Task<AuthenticationResult> ChangeRequiredPasswordAsync(
PasswordChangeChallengeRequest request,
CancellationToken cancellationToken = default)
@@ -317,37 +224,6 @@ public sealed class AuthService(
request.IpAddress, request.UserAgent, cancellationToken);
}
private async Task<AuthenticationResult> IssueFromChallengeAsync(
AuthChallenge challenge,
User user,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
if (!await HasBackendPermissionsAsync(
challenge.Realm, challenge.TenantId, user.Id, cancellationToken))
{
throw new InvalidAuthChallengeException("backend_access_revoked");
}
Tenant? tenant = null;
TenantMembership? membership = null;
if (challenge.Realm == AuthRealm.Tenant && challenge.TenantId.HasValue)
{
tenant = await dbContext.Tenants.SingleOrDefaultAsync(
item => item.Id == challenge.TenantId.Value && item.Status == TenantStatus.Active, cancellationToken);
membership = await FindActiveMembershipAsync(challenge.TenantId.Value, user.Id, cancellationToken);
if (tenant is null || membership is null)
{
throw new TenantAccessDeniedException();
}
}
return await IssueAuthenticatedResultAsync(
user, challenge.Realm, tenant, membership, challenge.Provider,
mfaSatisfied: true, null, ipAddress, userAgent, cancellationToken);
}
private async Task<AuthChallenge> FindChallengeAsync(
string token,
AuthChallengePurpose purpose,
@@ -431,21 +307,8 @@ public sealed class AuthService(
AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken);
}
var requiresMfa = await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken);
if (requiresMfa)
{
var hasAuthenticator = user.TwoFactorEnabled &&
!string.IsNullOrWhiteSpace(await userManager.GetAuthenticatorKeyAsync(user));
return await CreateChallengeResultAsync(
user, realm, tenantId,
hasAuthenticator ? AuthChallengePurpose.MfaVerification : AuthChallengePurpose.MfaEnrollment,
provider,
hasAuthenticator ? AuthenticationStatus.MfaRequired : AuthenticationStatus.MfaEnrollmentRequired,
ipAddress, userAgent, cancellationToken);
}
return await IssueAuthenticatedResultAsync(
user, realm, tenant, membership, provider, mfaSatisfied: false,
user, realm, tenant, membership, provider,
identifier, ipAddress, userAgent, cancellationToken);
}
@@ -455,7 +318,6 @@ public sealed class AuthService(
Tenant? tenant,
TenantMembership? membership,
string provider,
bool mfaSatisfied,
string? identifier,
string? ipAddress,
string? userAgent,
@@ -470,7 +332,6 @@ public sealed class AuthService(
realm,
tenant?.Id,
provider,
mfaSatisfied,
ipAddress,
userAgent),
cancellationToken);
@@ -799,10 +660,6 @@ public sealed class AuthService(
private static string HashChallengeToken(string token) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant();
private static string NormalizeTotp(string? code) =>
(code ?? string.Empty).Replace(" ", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal);
private async Task AddSecurityAuditAsync(
Guid userId,
Guid? tenantId,

View File

@@ -109,7 +109,7 @@ public sealed class AuthSessionStore(
try
{
await AssertRealmAccessAsync(
current.Realm, current.TenantId, current.UserId, current.MfaSatisfied, cancellationToken);
current.Realm, current.TenantId, current.UserId, cancellationToken);
}
catch (TenantAccessDeniedException)
{
@@ -133,7 +133,7 @@ public sealed class AuthSessionStore(
var request = new AuthSessionIssueRequest(
user.Id, user.Phone, user.Email, user.SecurityStamp ?? string.Empty,
current.Realm, current.TenantId, "refresh", current.MfaSatisfied,
current.Realm, current.TenantId, "refresh",
ipAddress, userAgent, current.TokenFamilyId, current.Id);
var next = CreateSession(request, nextId);
var nextToken = GenerateRefreshToken(next.Realm, next.TenantId, next.Id);
@@ -171,14 +171,14 @@ public sealed class AuthSessionStore(
try
{
await AssertRealmAccessAsync(
realm, tenantId, userId, session.MfaSatisfied, cancellationToken);
realm, tenantId, userId, cancellationToken);
}
catch (TenantAccessDeniedException)
{
return null;
}
return new AuthSessionValidationResult(userId, realm, tenantId, session.MfaSatisfied);
return new AuthSessionValidationResult(userId, realm, tenantId);
}
public async Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default)
@@ -256,7 +256,6 @@ public sealed class AuthSessionStore(
TokenFamilyId = request.TokenFamilyId ?? sessionId,
ParentSessionId = request.ParentSessionId,
SecurityStamp = request.SecurityStamp,
MfaSatisfied = request.MfaSatisfied,
Provider = request.Provider,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays),
IpAddress = request.IpAddress,
@@ -267,7 +266,7 @@ public sealed class AuthSessionStore(
{
var access = tokenService.CreateAccessToken(
request.UserId, session.Id, request.Phone, request.Email,
request.Realm, request.TenantId, request.MfaSatisfied);
request.Realm, request.TenantId);
return new AuthTokenPair(access.Token, refreshToken, access.ExpiresAt, session.ExpiresAt);
}
@@ -275,14 +274,13 @@ public sealed class AuthSessionStore(
AuthRealm realm,
Guid? tenantId,
Guid userId,
bool mfaSatisfied,
CancellationToken cancellationToken)
{
if (realm == AuthRealm.Tenant && tenantId.HasValue)
{
var active = await dbContext.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken) &&
await dbContext.TenantMemberships.AnyAsync(item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active, cancellationToken);
if (active && (!mfaSatisfied || await HasTenantBackendPermissionAsync(tenantId.Value, userId, cancellationToken)))
if (active)
{
return;
}
@@ -303,19 +301,6 @@ public sealed class AuthSessionStore(
throw new TenantAccessDeniedException();
}
private Task<bool> HasTenantBackendPermissionAsync(
Guid tenantId,
Guid userId,
CancellationToken cancellationToken) =>
(from userRole in dbContext.TenantBackendUserRoles
join role in dbContext.TenantBackendRoles on userRole.RoleId equals role.Id
join binding in dbContext.TenantBackendRolePermissions on role.Id equals binding.RoleId
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
where userRole.TenantId == tenantId && userRole.UserId == userId &&
binding.TenantId == tenantId && role.Status == BackendRoleStatus.Active &&
(permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both)
select permission.Id).AnyAsync(cancellationToken);
private async Task<int> RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now, CancellationToken cancellationToken)
{
var owner = await dbContext.AuthSessions.AsNoTracking()

View File

@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Identity;
namespace Tiku.Infrastructure.Auth;
public sealed class LetterAndDigitPasswordValidator<TUser> : IPasswordValidator<TUser>
where TUser : class
{
public Task<IdentityResult> ValidateAsync(
UserManager<TUser> manager,
TUser user,
string? password)
{
var valid = password is { Length: >= 8 } &&
password.Any(char.IsLetter) &&
password.Any(char.IsDigit);
return Task.FromResult(valid
? IdentityResult.Success
: IdentityResult.Failed(new IdentityError
{
Code = "PasswordRequiresLetterAndDigit",
Description = "Password must be at least 8 characters and contain both letters and digits."
}));
}
}

View File

@@ -17,8 +17,7 @@ public sealed class TokenService(IOptions<JwtOptions> options, IJwtKeyRing keyRi
string? phone,
string? email,
AuthRealm realm,
Guid? tenantId,
bool mfaSatisfied)
Guid? tenantId)
{
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(options.AccessTokenMinutes);
var claims = new List<Claim>
@@ -35,11 +34,6 @@ public sealed class TokenService(IOptions<JwtOptions> options, IJwtKeyRing keyRi
claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString()));
}
if (mfaSatisfied)
{
claims.Add(new Claim(TikuClaimTypes.Mfa, "mfa"));
}
if (!string.IsNullOrWhiteSpace(phone))
{
claims.Add(new Claim(TikuClaimTypes.Phone, phone));

View File

@@ -92,8 +92,7 @@ public static class DevelopmentPlatformAdminSeeder
Name = "Local Platform Administrator",
EmailConfirmed = true,
Status = UserStatus.Active,
ForcePasswordChange = true,
TwoFactorEnabled = false
ForcePasswordChange = true
};
var passwordHasher = new PasswordHasher<User>(Options.Create(new PasswordHasherOptions
{
@@ -145,7 +144,6 @@ public static class DevelopmentPlatformAdminSeeder
user.Email,
RoleCode,
ForcePasswordChange = true,
MfaEnrollmentRequired = true,
Source = "ef_core_use_seeding"
})
});
@@ -187,6 +185,6 @@ public static class DevelopmentPlatformAdminSeeder
Console.WriteLine("Development platform administrator created by EF Core data seeding.");
Console.WriteLine($" Account: {Email}");
Console.WriteLine($" Temporary password: {temporaryPassword}");
Console.WriteLine(" Change the password and enroll TOTP MFA at first sign-in. This password is shown only once.");
Console.WriteLine(" Change the temporary password at first sign-in. This password is shown only once.");
}
}

View File

@@ -78,8 +78,7 @@ public sealed class PlatformAdminBootstrapper(
Name = string.IsNullOrWhiteSpace(options.DisplayName) ? "Platform Administrator" : options.DisplayName.Trim(),
EmailConfirmed = true,
Status = UserStatus.Active,
ForcePasswordChange = true,
TwoFactorEnabled = false
ForcePasswordChange = true
};
var createResult = await userManager.CreateAsync(user, options.TemporaryPassword);
if (!createResult.Succeeded)
@@ -140,7 +139,7 @@ public sealed class PlatformAdminBootstrapper(
user.Email,
RoleCode = SuperAdminRoleCode,
ForcePasswordChange = true,
MfaEnrollmentRequired = true
LoginMethod = "account_password"
})
});

View File

@@ -68,9 +68,9 @@ public static class DependencyInjection
});
services.AddIdentityCore<User>(options =>
{
options.Password.RequiredLength = 10;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequiredLength = 8;
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5;
@@ -79,7 +79,8 @@ public static class DependencyInjection
})
.AddEntityFrameworkStores<TikuDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
.AddDefaultTokenProviders()
.AddPasswordValidator<LetterAndDigitPasswordValidator<User>>();
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
services.AddScoped<ITenantDirectory, TenantDirectory>();
services.AddMemoryCache();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RemoveMfaAuthentication : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "mfa_satisfied",
table: "auth_sessions");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "mfa_satisfied",
table: "auth_sessions",
type: "boolean",
nullable: false,
defaultValue: false);
}
}
}

View File

@@ -13318,10 +13318,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("MfaSatisfied")
.HasColumnType("boolean")
.HasColumnName("mfa_satisfied");
b.Property<Guid?>("ParentSessionId")
.HasColumnType("uuid")
.HasColumnName("parent_session_id");

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

View File

@@ -31,6 +31,23 @@ public sealed class AuthServiceTests
Assert.Equal(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "wrong"));
}
[Theory]
[InlineData("abc12345", true)]
[InlineData("ABC12345", true)]
[InlineData("abcdefgh", false)]
[InlineData("12345678", false)]
[InlineData("abc1234", false)]
public async Task Default_password_policy_requires_eight_characters_letters_and_digits(
string password,
bool expectedSuccess)
{
var validator = new LetterAndDigitPasswordValidator<User>();
var result = await validator.ValidateAsync(null!, new User(), password);
Assert.Equal(expectedSuccess, result.Succeeded);
}
[Fact]
public async Task Password_login_issues_tenant_session_for_regular_member()
{
@@ -70,48 +87,17 @@ public sealed class AuthServiceTests
}
[Fact]
public async Task Backend_permission_requires_one_time_mfa_enrollment_challenge()
public async Task Backend_permission_user_authenticates_without_an_additional_challenge()
{
await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true);
var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null));
Assert.Equal(AuthenticationStatus.MfaEnrollmentRequired, result.Status);
Assert.Null(result.User);
Assert.False(string.IsNullOrWhiteSpace(result.ChallengeToken));
Assert.Empty(await fixture.DbContext.AuthSessions.ToArrayAsync());
Assert.Single(await fixture.DbContext.AuthChallenges.ToArrayAsync());
}
[Fact]
public async Task Incomplete_authenticator_setup_still_requires_enrollment()
{
await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true);
var user = await fixture.UserManager.FindByIdAsync(fixture.UserId.ToString());
Assert.True((await fixture.UserManager.ResetAuthenticatorKeyAsync(user!)).Succeeded);
Assert.False(user!.TwoFactorEnabled);
var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null));
Assert.Equal(AuthenticationStatus.MfaEnrollmentRequired, result.Status);
}
[Fact]
public async Task Mfa_setup_audit_captures_request_origin()
{
await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true);
var login = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null));
await fixture.AuthService.SetupTotpAsync(new MfaChallengeRequest(
login.ChallengeToken!, null, "127.0.0.9", "mfa-audit-test"));
var audit = await fixture.DbContext.AuditLogs.SingleAsync(item =>
item.Action == "auth.mfa.enrollment_setup");
Assert.Equal("127.0.0.9", audit.IpAddress);
Assert.Equal("mfa-audit-test", audit.UserAgent);
Assert.Equal(AuthenticationStatus.Authenticated, result.Status);
Assert.NotNull(result.User);
Assert.Single(await fixture.DbContext.AuthSessions.ToArrayAsync());
Assert.Empty(await fixture.DbContext.AuthChallenges.ToArrayAsync());
}
private sealed class AuthFixture : IAsyncDisposable
@@ -146,9 +132,9 @@ public sealed class AuthServiceTests
options.UseInMemoryDatabase(Guid.NewGuid().ToString("N")));
services.AddIdentityCore<User>(options =>
{
options.Password.RequiredLength = 10;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequiredLength = 8;
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5;
@@ -156,7 +142,8 @@ public sealed class AuthServiceTests
})
.AddEntityFrameworkStores<TikuDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
.AddDefaultTokenProviders()
.AddPasswordValidator<LetterAndDigitPasswordValidator<User>>();
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
services.Configure<JwtOptions>(options =>
{

View File

@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Security;
using Tiku.Domain.Identity;
using Tiku.Infrastructure.Bootstrap;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
namespace Tiku.UnitTests.Bootstrap;
@@ -28,7 +29,6 @@ public sealed class PlatformAdminBootstrapperTests
var context = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var user = await context.Users.SingleAsync(item => item.Id == result.UserId);
Assert.True(user.ForcePasswordChange);
Assert.False(user.TwoFactorEnabled);
Assert.True(user.EmailConfirmed);
Assert.Equal(UserStatus.Active, user.Status);
var role = await context.PlatformBackendRoles.SingleAsync(item => item.Id == result.RoleId);
@@ -97,7 +97,6 @@ public sealed class PlatformAdminBootstrapperTests
var user = await context.Users.SingleAsync();
Assert.Equal(DevelopmentPlatformAdminSeeder.Email, user.Email);
Assert.True(user.ForcePasswordChange);
Assert.False(user.TwoFactorEnabled);
Assert.Equal(BackendPermissions.Platform.Count, await context.PlatformBackendRolePermissions.CountAsync());
Assert.Single(context.PlatformBackendUserRoles);
Assert.Single(context.AuditLogs);
@@ -127,14 +126,15 @@ public sealed class PlatformAdminBootstrapperTests
options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
services.AddIdentityCore<User>(options =>
{
options.Password.RequiredLength = 10;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequiredLength = 8;
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
})
.AddEntityFrameworkStores<TikuDbContext>()
.AddDefaultTokenProviders();
.AddDefaultTokenProviders()
.AddPasswordValidator<LetterAndDigitPasswordValidator<User>>();
services.AddDataProtection().UseEphemeralDataProtectionProvider();
return services.BuildServiceProvider();
}

View File

@@ -41,7 +41,6 @@ Tenant Active
+ Module / Feature 可用
+ Operation Permission
+ DataScope / Resource Scope
+ 必要时 MFA
```
验收:
@@ -53,7 +52,7 @@ Tenant Active
## P1接口最小权限与 DataScope 审计
- 建立 endpoint authorization manifestmethod、route、realm、module、permission、DataScope、MFA、audit action。
- 建立 endpoint authorization manifestmethod、route、realm、module、permission、DataScope、audit action。
- 后台写接口不得只使用 `[Authorize]`
- tenant/platform 权限不得串用。
- `[AllowAnonymous]` 只能出现在白名单路由。

View File

@@ -28,9 +28,9 @@ Client
## 账号与 Session
- 账号由 ASP.NET Core Identity 管理。
- 密码最少 10 位PBKDF2 迭代次数 210,000。
- 密码最少 8 位且必须同时包含字母和数字PBKDF2 迭代次数 210,000。
- 连续 5 次密码失败后锁定 15 分钟。
- TOTP、恢复码和 Authenticator Key 使用 Identity 标准能力
- 普通租户用户使用手机号和密码或手机号短信验证码登录;平台管理员当前使用账号和密码登录
- 微信等外部身份只保存 provider subject、openid、unionid不保存 `session_key` 或原始 secret。
- Data Protection key 持久化到 PostgreSQL非 Development 环境必须提供带私钥的 PKCS#12 证书保护 key ring。
@@ -40,7 +40,6 @@ Access token
- 固定 15 分钟。
- 包含 `sub``sid``jti``iat``iss``aud``exp``scope`
- tenant token 必须包含 `tid`platform token 禁止包含 `tid`
- 完成 MFA 的 Session 可包含 `amr=mfa`
- 不包含 role 或 permission claim。
Refresh token
@@ -83,7 +82,7 @@ Host 是认证上下文,不是普通参数。`TenantResolutionMiddleware` 在
- 租户角色、权限、菜单、用户角色绑定都带租户上下文。
- 平台角色不带租户键,不能自动读取租户业务数据。
- 菜单只决定 UI bootstrap 展示,不作为 API 授权依据。
- 后台 API 必须声明明确 permission高风险写操作按策略要求 MFA 和审计。
- 后台 API 必须声明明确 permission高风险写操作必须记录审计。
DataScope
@@ -103,14 +102,14 @@ DataScope
必须落审计:
- 登录、刷新重放、logout-all、强制改密、MFA 变更
- 登录、刷新重放、logout-all、强制改密
- 角色、权限、成员状态、租户状态、Provider 配置、支付运营动作;
- System Scope 和跨租户平台操作。
错误响应:
- 401未认证或 token/session 无效。
- 403已认证但 realm、tenant、permission、DataScope、MFA 或套餐能力不满足。
- 403已认证但 realm、tenant、permission、DataScope 或套餐能力不满足。
- 404未知 Host、不可见资源或需要隐藏存在性的资源。
- 响应不得泄露完整手机号、openId、邮箱、密钥、支付账号或内部 provider payload。

View File

@@ -64,11 +64,9 @@ DbMigrator 会执行全部 EF Core Migration并在全新 Development 数据
请立即保存终端显示的临时密码。重复执行 DbMigrator 是幂等的,不会重复创建管理员、重置密码或再次显示密码。
管理员首次登录后必须
管理员首次登录后必须修改临时密码。正式密码至少 8 位,并同时包含字母和数字;平台管理员当前使用账号和密码登录,不要求绑定认证器。
1. 修改临时密码;
2. 绑定 TOTP MFA
3. 保存一次性恢复码。
普通租户用户以手机号作为账号,可以使用手机号和密码登录,也可以使用手机号和短信验证码登录。
如果数据库已经包含平台管理员,自动初始化会跳过。不要为了重新获取密码删除包含业务数据的数据库。