feat(auth): replace TOTP with phone-first login
This commit is contained in:
@@ -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 警告与错误仍会输出。
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,7 +18,6 @@ public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next)
|
||||
{
|
||||
AuthRateLimitPolicies.Password => "identifier",
|
||||
AuthRateLimitPolicies.Sms => "phone",
|
||||
AuthRateLimitPolicies.Mfa => "challengeToken",
|
||||
_ => null
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -52,9 +52,7 @@
|
||||
"PasswordPermitLimit": 5,
|
||||
"PasswordWindowSeconds": 900,
|
||||
"SmsPermitLimit": 5,
|
||||
"SmsWindowSeconds": 300,
|
||||
"MfaPermitLimit": 5,
|
||||
"MfaWindowSeconds": 300
|
||||
"SmsWindowSeconds": 300
|
||||
}
|
||||
},
|
||||
"Authentication": {
|
||||
|
||||
@@ -29,12 +29,6 @@
|
||||
|
||||
function gate() { return document.querySelector('#platformAuthGate'); }
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>'"]/g, character => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"',
|
||||
})[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));
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,6 +10,5 @@ public interface ITokenService
|
||||
string? phone,
|
||||
string? email,
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
bool mfaSatisfied);
|
||||
Guid? tenantId);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
24
Tiku.Infrastructure/Auth/LetterAndDigitPasswordValidator.cs
Normal file
24
Tiku.Infrastructure/Auth/LetterAndDigitPasswordValidator.cs
Normal 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."
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
18099
Tiku.Infrastructure/Persistence/Migrations/20260728093337_RemoveMfaAuthentication.Designer.cs
generated
Normal file
18099
Tiku.Infrastructure/Persistence/Migrations/20260728093337_RemoveMfaAuthentication.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -142,13 +142,6 @@ public sealed class AuthEndpointTests
|
||||
new HttpRequestMessage(HttpMethod.Post, "/api/auth/logout")
|
||||
{
|
||||
Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken })
|
||||
},
|
||||
new HttpRequestMessage(HttpMethod.Post, "/api/auth/mfa/totp/setup")
|
||||
{
|
||||
Content = JsonContent.Create(new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = $"c1.p.-.{new string('b', 86)}"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Controllers;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthMfaLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Enrollment_returns_recovery_codes_once_then_subsequent_login_requires_mfa()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedBackendUserAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||
|
||||
using var login = await PostPasswordLoginAsync(client, seed);
|
||||
Assert.Equal("mfa_enrollment_required", login.RootElement.GetProperty("status").GetString());
|
||||
var challengeToken = login.RootElement.GetProperty("challengeToken").GetString()!;
|
||||
|
||||
var setupResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/setup",
|
||||
new MfaChallengeDto { ChallengeToken = challengeToken });
|
||||
setupResponse.EnsureSuccessStatusCode();
|
||||
using var setup = JsonDocument.Parse(await setupResponse.Content.ReadAsStringAsync());
|
||||
var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString()!;
|
||||
|
||||
var confirmRequest = new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = challengeToken,
|
||||
Code = AuthenticationTestClientExtensions.GenerateTotp(sharedKey)
|
||||
};
|
||||
var confirmResponse = await client.PostAsJsonAsync("/api/auth/mfa/totp/confirm", confirmRequest);
|
||||
confirmResponse.EnsureSuccessStatusCode();
|
||||
using var confirmation = JsonDocument.Parse(await confirmResponse.Content.ReadAsStringAsync());
|
||||
Assert.Equal(
|
||||
"authenticated",
|
||||
confirmation.RootElement.GetProperty("authentication").GetProperty("status").GetString());
|
||||
Assert.Equal(10, confirmation.RootElement.GetProperty("recoveryCodes").GetArrayLength());
|
||||
var recoveryCode = confirmation.RootElement.GetProperty("recoveryCodes")[0].GetString()!;
|
||||
|
||||
var replayResponse = await client.PostAsJsonAsync("/api/auth/mfa/totp/confirm", confirmRequest);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, replayResponse.StatusCode);
|
||||
|
||||
using var nextLogin = await PostPasswordLoginAsync(client, seed);
|
||||
Assert.Equal("mfa_required", nextLogin.RootElement.GetProperty("status").GetString());
|
||||
Assert.False(nextLogin.RootElement.TryGetProperty("recoveryCodes", out _));
|
||||
|
||||
var recoveryResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/verify",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = nextLogin.RootElement.GetProperty("challengeToken").GetString()!,
|
||||
Code = recoveryCode
|
||||
});
|
||||
recoveryResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var finalLogin = await PostPasswordLoginAsync(client, seed);
|
||||
var replayedRecoveryResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/verify",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = finalLogin.RootElement.GetProperty("challengeToken").GetString()!,
|
||||
Code = recoveryCode
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, replayedRecoveryResponse.StatusCode);
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify recovery code audit");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var recoveryAudits = await dbContext.AuditLogs
|
||||
.Where(item => item.Action == "auth.mfa.verified")
|
||||
.ToArrayAsync();
|
||||
var recoveryAudit = Assert.Single(recoveryAudits, item =>
|
||||
item.Details.ToString().Contains("recovery_code", StringComparison.Ordinal));
|
||||
Assert.Equal(seed.TenantId, recoveryAudit.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forced_password_change_precedes_mfa_enrollment()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedBackendUserAsync(factory, forcePasswordChange: true);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||
|
||||
using var login = await PostPasswordLoginAsync(client, seed);
|
||||
|
||||
Assert.Equal("password_change_required", login.RootElement.GetProperty("status").GetString());
|
||||
Assert.False(string.IsNullOrWhiteSpace(login.RootElement.GetProperty("challengeToken").GetString()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(AuthController.LoginWithPassword), "login/password")]
|
||||
[InlineData(nameof(AuthController.SendSmsCode), "sms/send")]
|
||||
[InlineData(nameof(AuthController.LoginWithSms), "login/sms")]
|
||||
[InlineData(nameof(AuthController.LoginWithWechatWeb), "oauth/wechat")]
|
||||
[InlineData(nameof(AuthController.LoginWithWechatMiniApp), "oauth/wechat-miniapp")]
|
||||
[InlineData(nameof(AuthController.SetupTotp), "mfa/totp/setup")]
|
||||
[InlineData(nameof(AuthController.ConfirmTotp), "mfa/totp/confirm")]
|
||||
[InlineData(nameof(AuthController.VerifyTotp), "mfa/totp/verify")]
|
||||
[InlineData(nameof(AuthController.Refresh), "refresh")]
|
||||
[InlineData(nameof(AuthController.Logout), "logout")]
|
||||
[InlineData(nameof(AuthController.LogoutAll), "logout-all")]
|
||||
public void Authentication_routes_match_the_v2_contract(string actionName, string route)
|
||||
{
|
||||
var action = typeof(AuthController).GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance);
|
||||
var attribute = action?.GetCustomAttribute<HttpPostAttribute>();
|
||||
|
||||
Assert.NotNull(attribute);
|
||||
Assert.Equal(route, attribute.Template);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> PostPasswordLoginAsync(
|
||||
HttpClient client,
|
||||
(Guid TenantId, string Phone) seed)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Identifier = seed.Phone,
|
||||
Password = PasswordTestUserExtensions.TestPassword
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
return JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, string Phone)> SeedBackendUserAsync(
|
||||
ApiTestFactory factory,
|
||||
bool forcePasswordChange = false)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
const string phone = "13800000000";
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "MFA Lifecycle Tenant"
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "MFA Lifecycle User",
|
||||
ForcePasswordChange = forcePasswordChange
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
return (tenantId, phone);
|
||||
}
|
||||
}
|
||||
122
Tiku.IntegrationTests/Api/AuthPasswordLifecycleTests.cs
Normal file
122
Tiku.IntegrationTests/Api/AuthPasswordLifecycleTests.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Controllers;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthPasswordLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Phone_and_password_login_authenticates_without_totp()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedUserAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||
|
||||
using var login = await PostPasswordLoginAsync(client, seed);
|
||||
|
||||
Assert.Equal("authenticated", login.RootElement.GetProperty("status").GetString());
|
||||
Assert.Equal(seed.Phone, login.RootElement.GetProperty("user").GetProperty("phone").GetString());
|
||||
Assert.False(login.RootElement.TryGetProperty("challengeToken", out var challenge) &&
|
||||
challenge.ValueKind == JsonValueKind.String);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forced_password_change_finishes_with_an_authenticated_session()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedUserAsync(factory, forcePasswordChange: true);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||
|
||||
using var login = await PostPasswordLoginAsync(client, seed);
|
||||
Assert.Equal("password_change_required", login.RootElement.GetProperty("status").GetString());
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/auth/password/change-required",
|
||||
new RequiredPasswordChangeDto
|
||||
{
|
||||
ChallengeToken = login.RootElement.GetProperty("challengeToken").GetString()!,
|
||||
NewPassword = "ChangedPassword2026"
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var changed = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal("authenticated", changed.RootElement.GetProperty("status").GetString());
|
||||
Assert.False(changed.RootElement.TryGetProperty("challengeToken", out var challenge) &&
|
||||
challenge.ValueKind == JsonValueKind.String);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(AuthController.LoginWithPassword), "login/password")]
|
||||
[InlineData(nameof(AuthController.SendSmsCode), "sms/send")]
|
||||
[InlineData(nameof(AuthController.LoginWithSms), "login/sms")]
|
||||
[InlineData(nameof(AuthController.LoginWithWechatWeb), "oauth/wechat")]
|
||||
[InlineData(nameof(AuthController.LoginWithWechatMiniApp), "oauth/wechat-miniapp")]
|
||||
[InlineData(nameof(AuthController.ChangeRequiredPassword), "password/change-required")]
|
||||
[InlineData(nameof(AuthController.Refresh), "refresh")]
|
||||
[InlineData(nameof(AuthController.Logout), "logout")]
|
||||
[InlineData(nameof(AuthController.LogoutAll), "logout-all")]
|
||||
public void Authentication_routes_match_the_v2_contract(string actionName, string route)
|
||||
{
|
||||
var action = typeof(AuthController).GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance);
|
||||
var attribute = action?.GetCustomAttribute<HttpPostAttribute>();
|
||||
|
||||
Assert.NotNull(attribute);
|
||||
Assert.Equal(route, attribute.Template);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> PostPasswordLoginAsync(
|
||||
HttpClient client,
|
||||
(Guid TenantId, string Phone) seed)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Identifier = seed.Phone,
|
||||
Password = PasswordTestUserExtensions.TestPassword
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
return JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, string Phone)> SeedUserAsync(
|
||||
ApiTestFactory factory,
|
||||
bool forcePasswordChange = false)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
const string phone = "13800000000";
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Password Lifecycle Tenant"
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Password Lifecycle User",
|
||||
ForcePasswordChange = forcePasswordChange
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
return (tenantId, phone);
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,7 @@ public sealed class AuthRateLimitPolicyTests
|
||||
[$"{AuthRateLimitOptions.SectionName}:PasswordPermitLimit"] = "7",
|
||||
[$"{AuthRateLimitOptions.SectionName}:PasswordWindowSeconds"] = "600",
|
||||
[$"{AuthRateLimitOptions.SectionName}:SmsPermitLimit"] = "3",
|
||||
[$"{AuthRateLimitOptions.SectionName}:SmsWindowSeconds"] = "90",
|
||||
[$"{AuthRateLimitOptions.SectionName}:MfaPermitLimit"] = "4",
|
||||
[$"{AuthRateLimitOptions.SectionName}:MfaWindowSeconds"] = "120"
|
||||
[$"{AuthRateLimitOptions.SectionName}:SmsWindowSeconds"] = "90"
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -37,8 +35,6 @@ public sealed class AuthRateLimitPolicyTests
|
||||
Assert.Equal(600, options.PasswordWindowSeconds);
|
||||
Assert.Equal(3, options.SmsPermitLimit);
|
||||
Assert.Equal(90, options.SmsWindowSeconds);
|
||||
Assert.Equal(4, options.MfaPermitLimit);
|
||||
Assert.Equal(120, options.MfaWindowSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -47,7 +43,7 @@ public sealed class AuthRateLimitPolicyTests
|
||||
var options = new AuthRateLimitOptions
|
||||
{
|
||||
PasswordPermitLimit = 0,
|
||||
MfaWindowSeconds = 0
|
||||
SmsWindowSeconds = 0
|
||||
};
|
||||
var validationResults = new List<ValidationResult>();
|
||||
|
||||
@@ -73,13 +69,10 @@ public sealed class AuthRateLimitPolicyTests
|
||||
AssertPolicy(nameof(AuthController.SendSmsCode), AuthRateLimitPolicies.Sms);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(AuthController.SetupTotp))]
|
||||
[InlineData(nameof(AuthController.ConfirmTotp))]
|
||||
[InlineData(nameof(AuthController.VerifyTotp))]
|
||||
public void Mfa_challenge_endpoints_use_the_mfa_named_policy(string methodName)
|
||||
[Fact]
|
||||
public void Required_password_change_uses_the_password_named_policy()
|
||||
{
|
||||
AssertPolicy(methodName, AuthRateLimitPolicies.Mfa);
|
||||
AssertPolicy(nameof(AuthController.ChangeRequiredPassword), AuthRateLimitPolicies.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -130,23 +123,6 @@ public sealed class AuthRateLimitPolicyTests
|
||||
Assert.DoesNotContain("13800000000", first, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mfa_partition_uses_the_challenge_token_and_resets_the_request_body()
|
||||
{
|
||||
const string body = """{"challengeToken":"challenge-one","code":"123456"}""";
|
||||
var first = await CapturePartitionAsync(
|
||||
AuthRateLimitPolicies.Mfa,
|
||||
body,
|
||||
"127.0.0.1");
|
||||
var second = await CapturePartitionAsync(
|
||||
AuthRateLimitPolicies.Mfa,
|
||||
"""{"challengeToken":"challenge-two","code":"123456"}""",
|
||||
"127.0.0.1");
|
||||
|
||||
Assert.NotEqual(first, second);
|
||||
Assert.DoesNotContain("challenge-one", first, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(string methodName, string expectedPolicy)
|
||||
{
|
||||
var method = typeof(AuthController).GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
@@ -150,7 +150,7 @@ public sealed class AuthSessionLifecycleTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_session_and_refresh_fail_immediately_after_the_last_permission_is_revoked()
|
||||
public async Task Tenant_session_remains_valid_after_a_backend_permission_is_revoked()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedActiveMemberAsync(factory);
|
||||
@@ -182,7 +182,7 @@ public sealed class AuthSessionLifecycleTests
|
||||
UserId = seed.UserId,
|
||||
RoleId = role.Id
|
||||
});
|
||||
var tokens = await IssueAsync(factory, seed, mfaSatisfied: true);
|
||||
var tokens = await IssueAsync(factory, seed);
|
||||
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Revoke final backend permission"))
|
||||
@@ -194,21 +194,12 @@ public sealed class AuthSessionLifecycleTests
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Validate revoked backend session"))
|
||||
using (var scope = factory.CreateSystemScope("Validate tenant session"))
|
||||
{
|
||||
var store = scope.ServiceProvider.GetRequiredService<IAuthSessionStore>();
|
||||
Assert.Null(await store.ValidateAccessSessionAsync(
|
||||
Assert.NotNull(await store.ValidateAccessSessionAsync(
|
||||
locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
|
||||
await Assert.ThrowsAsync<SessionRevokedException>(() =>
|
||||
store.RotateAsync(tokens.RefreshToken, null, null));
|
||||
}
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Verify revoked backend family"))
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var session = await dbContext.AuthSessions.SingleAsync(item => item.Id == locator.SessionId);
|
||||
Assert.NotNull(session.RevokedAt);
|
||||
Assert.Equal("realm_access_revoked", session.RevokedReason);
|
||||
Assert.NotNull(await store.RotateAsync(tokens.RefreshToken, null, null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,8 +227,7 @@ public sealed class AuthSessionLifecycleTests
|
||||
|
||||
private static async Task<AuthTokenPair> IssueAsync(
|
||||
ApiTestFactory factory,
|
||||
SessionSeed seed,
|
||||
bool mfaSatisfied = false)
|
||||
SessionSeed seed)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope("Issue authentication session");
|
||||
return await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>().IssueAsync(
|
||||
@@ -249,7 +239,6 @@ public sealed class AuthSessionLifecycleTests
|
||||
AuthRealm.Tenant,
|
||||
seed.TenantId,
|
||||
"integration-test",
|
||||
mfaSatisfied,
|
||||
"127.0.0.1",
|
||||
"integration-test"));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Domain.Identity;
|
||||
@@ -12,8 +10,6 @@ internal sealed record TestAuthenticationTokens(string AccessToken, string Refre
|
||||
|
||||
internal static class AuthenticationTestClientExtensions
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, string> AuthenticatorKeys = new(StringComparer.Ordinal);
|
||||
|
||||
public static async Task<TestAuthenticationTokens> LoginAsTenantAsync(
|
||||
this HttpClient client,
|
||||
Guid tenantId,
|
||||
@@ -56,123 +52,31 @@ internal static class AuthenticationTestClientExtensions
|
||||
this HttpClient client,
|
||||
HttpResponseMessage response,
|
||||
Guid tenantId,
|
||||
string authenticatorCacheKey)
|
||||
string _)
|
||||
{
|
||||
SetTenantHeader(client, tenantId);
|
||||
using var authentication = await ReadSuccessfulJsonAsync(response);
|
||||
var root = authentication.RootElement;
|
||||
var status = root.GetProperty("status").GetString();
|
||||
|
||||
if (string.Equals(status, "authenticated", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ReadTokens(root.GetProperty("user").GetProperty("tokens"));
|
||||
}
|
||||
|
||||
var challengeToken = root.GetProperty("challengeToken").GetString()
|
||||
?? throw new InvalidOperationException("Authentication challenge did not contain a challenge token.");
|
||||
var keyId = $"{tenantId:N}:{authenticatorCacheKey}";
|
||||
|
||||
if (string.Equals(status, "mfa_enrollment_required", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var setupResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/setup",
|
||||
new MfaChallengeDto { ChallengeToken = challengeToken });
|
||||
using var setup = await ReadSuccessfulJsonAsync(setupResponse);
|
||||
var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString()
|
||||
?? throw new InvalidOperationException("MFA setup did not return a shared key.");
|
||||
AuthenticatorKeys[keyId] = sharedKey;
|
||||
|
||||
var confirmResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/confirm",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = challengeToken,
|
||||
Code = GenerateTotp(sharedKey)
|
||||
});
|
||||
using var confirmation = await ReadSuccessfulJsonAsync(confirmResponse);
|
||||
return ReadTokens(
|
||||
confirmation.RootElement
|
||||
.GetProperty("authentication")
|
||||
.GetProperty("user")
|
||||
.GetProperty("tokens"));
|
||||
}
|
||||
|
||||
if (string.Equals(status, "mfa_required", StringComparison.OrdinalIgnoreCase) &&
|
||||
AuthenticatorKeys.TryGetValue(keyId, out var existingKey))
|
||||
{
|
||||
var verifyResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/verify",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = challengeToken,
|
||||
Code = GenerateTotp(existingKey)
|
||||
});
|
||||
using var verification = await ReadSuccessfulJsonAsync(verifyResponse);
|
||||
return ReadTokens(verification.RootElement.GetProperty("user").GetProperty("tokens"));
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unsupported test authentication status '{status}'.");
|
||||
return string.Equals(status, "authenticated", StringComparison.OrdinalIgnoreCase)
|
||||
? ReadTokens(root.GetProperty("user").GetProperty("tokens"))
|
||||
: throw new InvalidOperationException($"Unsupported test authentication status '{status}'.");
|
||||
}
|
||||
|
||||
private static async Task<TestAuthenticationTokens> CompletePlatformAuthenticationAsync(
|
||||
this HttpClient client,
|
||||
HttpResponseMessage response,
|
||||
string authenticatorCacheKey)
|
||||
string _)
|
||||
{
|
||||
client.DefaultRequestHeaders.Remove("x-tenant-code");
|
||||
using var authentication = await ReadSuccessfulJsonAsync(response);
|
||||
var root = authentication.RootElement;
|
||||
var status = root.GetProperty("status").GetString();
|
||||
|
||||
if (string.Equals(status, "authenticated", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ReadTokens(root.GetProperty("user").GetProperty("tokens"));
|
||||
}
|
||||
|
||||
var challengeToken = root.GetProperty("challengeToken").GetString()
|
||||
?? throw new InvalidOperationException("Authentication challenge did not contain a challenge token.");
|
||||
var keyId = $"platform:{authenticatorCacheKey}";
|
||||
|
||||
if (string.Equals(status, "mfa_enrollment_required", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var setupResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/setup",
|
||||
new MfaChallengeDto { ChallengeToken = challengeToken });
|
||||
using var setup = await ReadSuccessfulJsonAsync(setupResponse);
|
||||
var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString()
|
||||
?? throw new InvalidOperationException("MFA setup did not return a shared key.");
|
||||
AuthenticatorKeys[keyId] = sharedKey;
|
||||
|
||||
var confirmResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/confirm",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = challengeToken,
|
||||
Code = GenerateTotp(sharedKey)
|
||||
});
|
||||
using var confirmation = await ReadSuccessfulJsonAsync(confirmResponse);
|
||||
return ReadTokens(
|
||||
confirmation.RootElement
|
||||
.GetProperty("authentication")
|
||||
.GetProperty("user")
|
||||
.GetProperty("tokens"));
|
||||
}
|
||||
|
||||
if (string.Equals(status, "mfa_required", StringComparison.OrdinalIgnoreCase) &&
|
||||
AuthenticatorKeys.TryGetValue(keyId, out var existingKey))
|
||||
{
|
||||
var verifyResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/verify",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = challengeToken,
|
||||
Code = GenerateTotp(existingKey)
|
||||
});
|
||||
using var verification = await ReadSuccessfulJsonAsync(verifyResponse);
|
||||
return ReadTokens(verification.RootElement.GetProperty("user").GetProperty("tokens"));
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unsupported test authentication status '{status}'.");
|
||||
return string.Equals(status, "authenticated", StringComparison.OrdinalIgnoreCase)
|
||||
? ReadTokens(root.GetProperty("user").GetProperty("tokens"))
|
||||
: throw new InvalidOperationException($"Unsupported test authentication status '{status}'.");
|
||||
}
|
||||
|
||||
public static void UseAccessToken(this HttpClient client, TestAuthenticationTokens tokens)
|
||||
@@ -207,56 +111,4 @@ internal static class AuthenticationTestClientExtensions
|
||||
return new TestAuthenticationTokens(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
internal static string GenerateTotp(string sharedKey)
|
||||
{
|
||||
var secret = DecodeBase32(sharedKey);
|
||||
var counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30;
|
||||
Span<byte> counterBytes = stackalloc byte[8];
|
||||
for (var index = counterBytes.Length - 1; index >= 0; index--)
|
||||
{
|
||||
counterBytes[index] = (byte)(counter & 0xff);
|
||||
counter >>= 8;
|
||||
}
|
||||
|
||||
var hash = HMACSHA1.HashData(secret, counterBytes);
|
||||
var offset = hash[^1] & 0x0f;
|
||||
var binaryCode = ((hash[offset] & 0x7f) << 24) |
|
||||
(hash[offset + 1] << 16) |
|
||||
(hash[offset + 2] << 8) |
|
||||
hash[offset + 3];
|
||||
return (binaryCode % 1_000_000).ToString("D6", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static byte[] DecodeBase32(string value)
|
||||
{
|
||||
var normalized = value.Replace(" ", string.Empty, StringComparison.Ordinal)
|
||||
.TrimEnd('=')
|
||||
.ToUpperInvariant();
|
||||
var output = new byte[normalized.Length * 5 / 8];
|
||||
var buffer = 0;
|
||||
var bitsInBuffer = 0;
|
||||
var outputIndex = 0;
|
||||
|
||||
foreach (var character in normalized)
|
||||
{
|
||||
var digit = character switch
|
||||
{
|
||||
>= 'A' and <= 'Z' => character - 'A',
|
||||
>= '2' and <= '7' => character - '2' + 26,
|
||||
_ => throw new FormatException("Authenticator shared key is not valid Base32.")
|
||||
};
|
||||
buffer = (buffer << 5) | digit;
|
||||
bitsInBuffer += 5;
|
||||
if (bitsInBuffer < 8)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
output[outputIndex++] = (byte)(buffer >> (bitsInBuffer - 8));
|
||||
bitsInBuffer -= 8;
|
||||
buffer &= (1 << bitsInBuffer) - 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,6 @@ public sealed class PlatformAdminStaticEndpointTests
|
||||
Assert.Contains("fallbackToMock: false", runtime, StringComparison.Ordinal);
|
||||
Assert.Contains("/api/auth/login/password", authentication, StringComparison.Ordinal);
|
||||
Assert.Contains("/api/auth/password/change-required", authentication, StringComparison.Ordinal);
|
||||
Assert.Contains("/api/auth/mfa/totp/confirm", authentication, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("/api/auth/mfa", authentication, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace Tiku.IntegrationTests.Api;
|
||||
public sealed class RbacAuthorizationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TenantPolicy_RequiresCurrentMembershipPermissionAndMfa()
|
||||
public async Task TenantPolicy_requires_current_membership_and_permission()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
@@ -21,20 +21,15 @@ public sealed class RbacAuthorizationTests
|
||||
var authorization = provider.GetRequiredService<IAuthorizationService>();
|
||||
|
||||
var allowed = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: true),
|
||||
null,
|
||||
BackendPermissions.TenantRoleManage);
|
||||
var missingMfa = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: false),
|
||||
Principal(userId, "tenant", tenantId),
|
||||
null,
|
||||
BackendPermissions.TenantRoleManage);
|
||||
var wrongTenant = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", Guid.NewGuid(), hasMfa: true),
|
||||
Principal(userId, "tenant", Guid.NewGuid()),
|
||||
null,
|
||||
BackendPermissions.TenantRoleManage);
|
||||
|
||||
Assert.True(allowed.Succeeded);
|
||||
Assert.False(missingMfa.Succeeded);
|
||||
Assert.False(wrongTenant.Succeeded);
|
||||
}
|
||||
|
||||
@@ -51,11 +46,11 @@ public sealed class RbacAuthorizationTests
|
||||
var authorization = provider.GetRequiredService<IAuthorizationService>();
|
||||
|
||||
var tenantRealm = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: true),
|
||||
Principal(userId, "tenant", tenantId),
|
||||
null,
|
||||
BackendPermissions.PlatformRoleManage);
|
||||
var platformRealm = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "platform", null, hasMfa: true),
|
||||
Principal(userId, "platform", null),
|
||||
null,
|
||||
BackendPermissions.PlatformRoleManage);
|
||||
|
||||
@@ -71,7 +66,7 @@ public sealed class RbacAuthorizationTests
|
||||
var snapshot = Snapshot(userId, tenantId);
|
||||
await using var provider = Services(snapshot);
|
||||
var authorization = provider.GetRequiredService<IAuthorizationService>();
|
||||
var principal = Principal(userId, "tenant", tenantId, hasMfa: true);
|
||||
var principal = Principal(userId, "tenant", tenantId);
|
||||
((ClaimsIdentity)principal.Identity!).AddClaim(new Claim(ClaimTypes.Role, "TenantOwner"));
|
||||
|
||||
var result = await authorization.AuthorizeAsync(
|
||||
@@ -87,7 +82,7 @@ public sealed class RbacAuthorizationTests
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var principal = Principal(userId, "tenant", tenantId, hasMfa: true);
|
||||
var principal = Principal(userId, "tenant", tenantId);
|
||||
var selfSnapshot = Snapshot(
|
||||
userId,
|
||||
tenantId,
|
||||
@@ -128,7 +123,7 @@ public sealed class RbacAuthorizationTests
|
||||
true);
|
||||
await using var provider = Services(Snapshot(userId, tenantId, dataScope: scope));
|
||||
var authorization = provider.GetRequiredService<IAuthorizationService>();
|
||||
var principal = Principal(userId, "tenant", tenantId, hasMfa: true);
|
||||
var principal = Principal(userId, "tenant", tenantId);
|
||||
var requirement = new TenantResourceAccessRequirement();
|
||||
|
||||
var own = await authorization.AuthorizeAsync(
|
||||
@@ -168,7 +163,7 @@ public sealed class RbacAuthorizationTests
|
||||
await using var tenantProvider = Services(tenantSnapshot);
|
||||
var tenantAuthorization = tenantProvider.GetRequiredService<IAuthorizationService>();
|
||||
var tenantAllowed = await tenantAuthorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: true),
|
||||
Principal(userId, "tenant", tenantId),
|
||||
null,
|
||||
TikuPolicies.TenantBackofficeBootstrap);
|
||||
|
||||
@@ -179,11 +174,11 @@ public sealed class RbacAuthorizationTests
|
||||
await using var platformProvider = Services(platformSnapshot);
|
||||
var platformAuthorization = platformProvider.GetRequiredService<IAuthorizationService>();
|
||||
var platformAllowed = await platformAuthorization.AuthorizeAsync(
|
||||
Principal(userId, "platform", null, hasMfa: true),
|
||||
Principal(userId, "platform", null),
|
||||
null,
|
||||
TikuPolicies.PlatformBackofficeBootstrap);
|
||||
var tenantRealmDenied = await platformAuthorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: true),
|
||||
Principal(userId, "tenant", tenantId),
|
||||
null,
|
||||
TikuPolicies.PlatformBackofficeBootstrap);
|
||||
|
||||
@@ -204,8 +199,7 @@ public sealed class RbacAuthorizationTests
|
||||
private static ClaimsPrincipal Principal(
|
||||
Guid userId,
|
||||
string realm,
|
||||
Guid? tenantId,
|
||||
bool hasMfa)
|
||||
Guid? tenantId)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
@@ -217,11 +211,6 @@ public sealed class RbacAuthorizationTests
|
||||
claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString()));
|
||||
}
|
||||
|
||||
if (hasMfa)
|
||||
{
|
||||
claims.Add(new Claim(TikuClaimTypes.Mfa, "totp"));
|
||||
}
|
||||
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
}
|
||||
|
||||
|
||||
@@ -94,29 +94,6 @@ public sealed class SecurityFoundationTests
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Jwt_mfa_claim_must_match_the_database_session()
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
var userId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
client.DefaultRequestHeaders.Authorization = new(
|
||||
"Bearer",
|
||||
TestJwtKeys.CreateToken([
|
||||
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString()),
|
||||
new Claim(TikuClaimTypes.Mfa, "mfa")
|
||||
]));
|
||||
|
||||
var response = await client.GetAsync("/api/_security/authenticated");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Jwt_with_unknown_kid_is_rejected()
|
||||
{
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ Tenant Active
|
||||
+ Module / Feature 可用
|
||||
+ Operation Permission
|
||||
+ DataScope / Resource Scope
|
||||
+ 必要时 MFA
|
||||
```
|
||||
|
||||
验收:
|
||||
@@ -53,7 +52,7 @@ Tenant Active
|
||||
|
||||
## P1:接口最小权限与 DataScope 审计
|
||||
|
||||
- 建立 endpoint authorization manifest:method、route、realm、module、permission、DataScope、MFA、audit action。
|
||||
- 建立 endpoint authorization manifest:method、route、realm、module、permission、DataScope、audit action。
|
||||
- 后台写接口不得只使用 `[Authorize]`。
|
||||
- tenant/platform 权限不得串用。
|
||||
- `[AllowAnonymous]` 只能出现在白名单路由。
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -64,11 +64,9 @@ DbMigrator 会执行全部 EF Core Migration,并在全新 Development 数据
|
||||
|
||||
请立即保存终端显示的临时密码。重复执行 DbMigrator 是幂等的,不会重复创建管理员、重置密码或再次显示密码。
|
||||
|
||||
管理员首次登录后必须:
|
||||
管理员首次登录后必须修改临时密码。正式密码至少 8 位,并同时包含字母和数字;平台管理员当前使用账号和密码登录,不要求绑定认证器。
|
||||
|
||||
1. 修改临时密码;
|
||||
2. 绑定 TOTP MFA;
|
||||
3. 保存一次性恢复码。
|
||||
普通租户用户以手机号作为账号,可以使用手机号和密码登录,也可以使用手机号和短信验证码登录。
|
||||
|
||||
如果数据库已经包含平台管理员,自动初始化会跳过。不要为了重新获取密码删除包含业务数据的数据库。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user