forked from gongxuegit/tiku-backend.net
feat(auth): replace TOTP with phone-first login
This commit is contained in:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user