R3 two-factor sign-in: TOTP with recovery codes, asked for only after a correct password
Build / Build (push) Successful in 36s
Build / Build (push) Successful in 36s
Completion program R3 (docs/ROADMAP.md, 2026-10-17). - Services/TwoFactor.cs: TOTP (RFC 6238, 30 s, 6 digits, SHA-1, one step of slack, Otp.NET 1.4.1) and ten recovery codes (SHA-256 kept, pulled when used). RootUser.Totp holds the secret, the last step taken (moved only by a conditional update, so a code works once even for two sign-ins at the same moment) and the codes' hashes. - /clientapi/user/totp/setup (the password) answers the secret, its otpauth:// URI and a QR code as an SVG data URI (QRCoder 1.8.0); /totp/enable takes the app's first code, shows the recovery codes once and ends every other session (this device gets a new one); /totp/disable takes the password and a code. GET /clientapi/user/account says whether it is on and how many recovery codes are left. - Sign-in (/clientapi/user/login, the invitation login, /oauth/login) asks for LoginForm.OneTimeCode with a 428, and only once the password was right; the OAuth page has the field. Recovery by email leaves two-factor on. - PrivaPub admin reset-totp <root>: the way back for a login that lost its authenticator and its codes. - scenarios/accounts.sh checks it all, with codes computed in Python; the OAuth page with its antiforgery token. Verified: dotnet build PrivaPub.sln -c Release; the pasture (gts, mailpit): accounts 45/45. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
cff75e3a47
commit
1f3bb27e17
13 files changed
+372
-5
No files matched your search
@@ -113,6 +113,14 @@ namespace PrivaPub.Services
|
||||
return result.Invalidate(Localizer[NoMatch]);
|
||||
if (user.IsBanned)
|
||||
return result.Invalidate(Localizer["User '{0}' banned.", user.UserName]);
|
||||
// only a correct password learns that two-factor is on: it is asked for with a 428
|
||||
if (user.Totp?.EnabledAt != default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(loginForm.OneTimeCode))
|
||||
return result.Invalidate(Localizer[TwoFactor.CodeNeeded], StatusCodes.Status428PreconditionRequired);
|
||||
if (!await TwoFactor.Consume(user, loginForm.OneTimeCode, CancellationToken.None))
|
||||
return result.Invalidate(Localizer[TwoFactor.CodeInvalid]);
|
||||
}
|
||||
|
||||
// hashed again at today's iterations; matched on the old hash, so two sign-ins at once never undo each other
|
||||
if (needsUpgrade)
|
||||
@@ -421,7 +429,9 @@ namespace PrivaPub.Services
|
||||
Email = user.Email,
|
||||
IsEmailValidated = user.IsEmailValidated,
|
||||
PendingEmail = pending?.Email,
|
||||
CreatedAt = user.CreatedAt
|
||||
CreatedAt = user.CreatedAt,
|
||||
TwoFactorEnabled = user.Totp?.EnabledAt != default,
|
||||
RecoveryCodesLeft = user.Totp?.EnabledAt != default ? user.Totp.RecoveryCodeHashes.Count : 0
|
||||
};
|
||||
return result;
|
||||
}
|
||||
@@ -500,6 +510,92 @@ namespace PrivaPub.Services
|
||||
}
|
||||
}
|
||||
|
||||
// Two-factor sign-in (TwoFactor): set up with the password, turned on by the app's first code (which also proves the
|
||||
// app holds the secret), turned off with the password and a code. Turning it on ends every other session.
|
||||
public async Task<WebResult> TotpSetupAsync(string rootId, string password)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
|
||||
var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, password ?? string.Empty);
|
||||
if (user?.HashedPassword == default || !verified)
|
||||
return result.Invalidate(Localizer["Wrong password."], StatusCodes.Status403Forbidden);
|
||||
if (user.Totp?.EnabledAt != default)
|
||||
return result.Invalidate(Localizer["Two-factor sign-in is on already: turn it off first."], StatusCodes.Status409Conflict);
|
||||
|
||||
var secret = TwoFactor.NewSecret();
|
||||
await DB.Default.Update<RootUser>().MatchID(rootId)
|
||||
.Modify(u => u.Totp, new RootTotp { PendingSecret = secret, PendingSince = DateTime.UtcNow })
|
||||
.ExecuteAsync();
|
||||
var uri = TwoFactor.OtpAuthUri(secret, user.UserName);
|
||||
result.Data = new ViewTotpSetup { Secret = secret, OtpAuthUri = uri, QrDataUri = TwoFactor.QrDataUri(uri) };
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(TotpSetupAsync)}()");
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebResult> TotpEnableAsync(string rootId, string code)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
|
||||
if (user?.Totp is not { EnabledAt: null, PendingSecret: not null } pending || pending.PendingSince < DateTime.UtcNow.AddHours(-1))
|
||||
return result.Invalidate(Localizer["Set up two-factor sign-in again: that setup expired."], StatusCodes.Status409Conflict);
|
||||
if (TwoFactor.Step(pending.PendingSecret, code) is not { } step)
|
||||
return result.Invalidate(Localizer[TwoFactor.CodeInvalid]);
|
||||
|
||||
var codes = TwoFactor.NewRecoveryCodes();
|
||||
await DB.Default.Update<RootUser>().MatchID(rootId)
|
||||
.Modify(u => u.Totp, new RootTotp
|
||||
{
|
||||
Secret = pending.PendingSecret,
|
||||
EnabledAt = DateTime.UtcNow,
|
||||
LastStep = step,
|
||||
RecoveryCodeHashes = codes.Select(TwoFactor.HashRecovery).ToList()
|
||||
})
|
||||
.ExecuteAsync();
|
||||
await Sessions.Revoke(rootId, CancellationToken.None);
|
||||
user = await DbEntities.RootUsers.MatchID(rootId).ExecuteFirstAsync();
|
||||
result.Data = (user, ToViewSettings(user.Settings), codes);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(TotpEnableAsync)}()");
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebResult> TotpDisableAsync(string rootId, string password, string code)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
|
||||
var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, password ?? string.Empty);
|
||||
if (user?.HashedPassword == default || !verified)
|
||||
return result.Invalidate(Localizer["Wrong password."], StatusCodes.Status403Forbidden);
|
||||
if (user.Totp?.EnabledAt == default)
|
||||
return await AccountAsync(rootId);
|
||||
if (!await TwoFactor.Consume(user, code, CancellationToken.None))
|
||||
return result.Invalidate(Localizer[TwoFactor.CodeInvalid]);
|
||||
|
||||
await DB.Default.Update<RootUser>().MatchID(rootId).Modify(u => u.Totp, null).ExecuteAsync();
|
||||
return await AccountAsync(rootId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(TotpDisableAsync)}()");
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebResult> RemoveEmailAsync(string rootId, string password)
|
||||
{
|
||||
var result = new WebResult();
|
||||
|
||||
Reference in new issue
Block a user