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
@@ -362,6 +362,37 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/totp/setup"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> TotpSetup(PasswordForm form)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
|
||||
var result = await UsersService.TotpSetupAsync(User.GetUserId(), form.Password);
|
||||
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
|
||||
// on with the app's first code; every other session ends, and this device goes on with a new one
|
||||
[HttpPost, Route("/clientapi/user/totp/enable"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> TotpEnable(TotpCodeForm form)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
|
||||
var result = await UsersService.TotpEnableAsync(User.GetUserId(), form.Code);
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
var (user, userSettings, codes) = ((RootUser, ViewUserSettings, List<string>))result.Data;
|
||||
return Ok(new ViewTotpEnabled { RecoveryCodes = codes, Session = AuthTokenManager.GenerateToken(user, userSettings) });
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/totp/disable"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> TotpDisable(TotpDisableForm form)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
|
||||
var result = await UsersService.TotpDisableAsync(User.GetUserId(), form.Password, form.Code);
|
||||
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/email/remove"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> RemoveEmail(PasswordForm form)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user