R2 accounts are safe: email by password and code, recovery only to it, sessions that end
Build / Build (push) Successful in 26s
Build / Build (push) Successful in 26s
Completion program R2 (docs/ROADMAP.md, 2026-10-17).
- An email address is set only with the password and a 6-digit code sent to it: /clientapi/user/email, /email/verify,
/email/remove and GET /clientapi/user/account (EmailVerification: an hour, five tries, the code's hash only;
EmailVerificationJob through the new IMailer). The request answers one sentence whether or not the address is free,
and nothing goes to an address another login verified. The old POST /clientapi/user/update, which changed the
address with neither and said when one was taken, answers 422.
- Recovery writes only to a verified address; migration _019 keeps the addresses given before verification existed.
- A changed password ends every session and every persona token, and answers a new JWT for the device that changed it.
- Sessions: every JWT carries its session's id ("si", kept by sniff/again). GET /clientapi/user/logout, which did
nothing, ends that session alone (RootUser.EndedSessions, until its tokens would expire); POST
/clientapi/user/logout/everywhere ends them all.
- AppConfiguration:HashingOptions was never bound, so passwords were hashed at the default 9789 iterations: it is
bound now, at 210,000, and a hash made at other iterations is made again at the next sign-in (matched on the old
hash, so two sign-ins never undo each other).
- Recovery and verification codes expire through TTL indexes; EmailVerification is never in a backup.
- AppConfigurationService reads appsettings as they are now, not the copy older builds saved in Mongo on first boot.
- ViewUserSettings and RootUserSettings carry SavedAt, so a client signing in keeps its own theme until one saved.
- Pasture: peers/mailpit.sh (Mailpit 1.31.4 as mailpit.test) receives PrivaPub's mail; recovery links open
https://decepub.test. scenarios/accounts.sh, in sweep batch A, checks all of the above (28 checks).
Verified: dotnet build PrivaPub.sln -c Release; the pasture with gts and mailpit: accounts 28/28 and gts, 91 passed,
0 failed.
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
ff5e635dda
commit
cff75e3a47
27 files changed
+636
-117
No files matched your search
@@ -292,12 +292,19 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
return default;
|
||||
}
|
||||
|
||||
// This device signs out: its session (every token it renewed) is refused from now on. A token from before sessions had
|
||||
// ids can't be told apart from the others, so it signs every device out.
|
||||
[HttpGet, Route("/clientapi/user/logout"), Authorize(Policy = Policies.IsUser)]
|
||||
public IActionResult Logout()
|
||||
public async Task<IActionResult> Logout([FromServices] IRootSessions sessions, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var sessionId = User.FindFirst(AuthTokenManager.SessionId)?.Value;
|
||||
if (string.IsNullOrEmpty(sessionId))
|
||||
await sessions.Revoke(User.GetUserId(), token);
|
||||
else
|
||||
await sessions.End(User.GetUserId(), sessionId, token);
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -307,27 +314,63 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/update"), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> UpdateUser(UserForm userEmailForm)
|
||||
[HttpPost, Route("/clientapi/user/logout/everywhere"), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> LogoutEverywhere([FromServices] IRootSessions sessions, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||
try
|
||||
{
|
||||
result = await UsersService.UpdateUserAsync(userEmailForm, User.GetUserId());
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
await sessions.Revoke(User.GetUserId(), token);
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUser)}()");
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(LogoutEverywhere)}()");
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
// The address is changed only with the password and a code sent to it (/clientapi/user/email); this older route, which
|
||||
// took neither and said whether an address was taken, no longer changes anything.
|
||||
[HttpPost, Route("/clientapi/user/update"), Authorize(Policy = Policies.IsUser)]
|
||||
public IActionResult UpdateUser(UserForm userEmailForm) =>
|
||||
StatusCode(StatusCodes.Status422UnprocessableEntity,
|
||||
new WebResult().Invalidate(Localizer["Changing the address takes your password and a code sent to it."], StatusCodes.Status422UnprocessableEntity));
|
||||
|
||||
[HttpGet, Route("/clientapi/user/account"), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> Account()
|
||||
{
|
||||
var result = await UsersService.AccountAsync(User.GetUserId());
|
||||
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/email"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> RequestEmailChange(EmailChangeForm form)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
|
||||
var result = await UsersService.RequestEmailChangeAsync(User.GetUserId(), form);
|
||||
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/email/verify"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> VerifyEmail(EmailCodeForm form)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
|
||||
var result = await UsersService.VerifyEmailAsync(User.GetUserId(), 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)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
|
||||
var result = await UsersService.RemoveEmailAsync(User.GetUserId(), form.Password);
|
||||
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/update/settings"), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> UpdateUserSettings(ViewUserSettings userSettings)
|
||||
{
|
||||
@@ -361,7 +404,9 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
return Ok();
|
||||
// every session ended with the change; this device goes on with a new one
|
||||
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
||||
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -500,7 +545,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
return Ok(AuthTokenManager.GenerateToken(user, (ViewUserSettings)result.Data));
|
||||
return Ok(AuthTokenManager.GenerateToken(user, (ViewUserSettings)result.Data, User.FindFirst(AuthTokenManager.SessionId)?.Value));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user