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
+635
-116
No files matched your search
@@ -406,6 +406,20 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
|
|||||||
queues a `SendRecovery` job, found or not; `RecoveryJob` sends the email and keeps only a SHA-256 of the code, for an
|
queues a `SendRecovery` job, found or not; `RecoveryJob` sends the email and keeps only a SHA-256 of the code, for an
|
||||||
hour. A recovered password ends every session of the root (`RootSessions`: a new `SessionStamp`, which every `/clientapi`
|
hour. A recovered password ends every session of the root (`RootSessions`: a new `SessionStamp`, which every `/clientapi`
|
||||||
JWT carries, and OpenIddict revocation for each persona).
|
JWT carries, and OpenIddict revocation for each persona).
|
||||||
|
- **Accounts and sessions** (completion program R2, 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`; `EmailVerification`, five tries, an hour). The request answers one sentence
|
||||||
|
whether or not the address is free; `EmailVerificationJob` sends nothing to an address another login verified.
|
||||||
|
Recovery writes only to a verified address (`IsEmailValidated`; migration `_019` kept the addresses given before).
|
||||||
|
`POST /clientapi/user/update` no longer changes the address (422).
|
||||||
|
- A changed password ends every session too, and answers a new JWT for the device that changed it.
|
||||||
|
- Every JWT carries its session's id (`si`, kept by renewals). `GET /clientapi/user/logout` ends that session alone
|
||||||
|
(`RootUser.EndedSessions`, until its tokens would expire); `POST /clientapi/user/logout/everywhere` ends them all.
|
||||||
|
- Passwords are PBKDF2-SHA512 at `AppConfiguration:HashingOptions:Iterations` (210,000; the option was never bound
|
||||||
|
before 2026-10-17), and a hash made at other iterations is made again at the next sign-in.
|
||||||
|
- Sign-up still says a login name is taken: uniqueness has to be told, and sign-up is reachable only while setup
|
||||||
|
waits or with an invitation, rate-limited.
|
||||||
|
- `AppConfigurationService` reads appsettings as they are now; the copy older builds saved in Mongo is not read.
|
||||||
- **Deleting a root deletes everything public it had** (`RootRemoval`, from the admin route or `/clientapi/user/delete`
|
- **Deleting a root deletes everything public it had** (`RootRemoval`, from the admin route or `/clientapi/user/delete`
|
||||||
with the password): its sessions end, each persona and each group it owns sends `Delete{Actor}` to followers, members
|
with the password): its sessions end, each persona and each group it owns sends `Delete{Actor}` to followers, members
|
||||||
and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer
|
and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer
|
||||||
@@ -528,7 +542,7 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
|
|||||||
backup rotates out), copies where a link can't be made. `--db-only` lists them instead.
|
backup rotates out), copies where a link can't be made. `--db-only` lists them instead.
|
||||||
|
|
||||||
**Never in a backup** (`ServerBackup.Excluded`): `InteractionSalt` (owner rule), `Job` and `Delivery` (work in flight),
|
**Never in a backup** (`ServerBackup.Excluded`): `InteractionSalt` (owner rule), `Job` and `Delivery` (work in flight),
|
||||||
`EmailRecovery`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, `ServerSetup` (a restore
|
`EmailRecovery` and `EmailVerification`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, `ServerSetup` (a restore
|
||||||
never opens setup again), and `AppConfiguration` (its SMTP password; it is made again from appsettings at boot). One
|
never opens setup again), and `AppConfiguration` (its SMTP password; it is made again from appsettings at boot). One
|
||||||
backup or restore runs at a time
|
backup or restore runs at a time
|
||||||
(`MaintenanceLock`, a heartbeat document; a holder silent for 2 minutes is taken over), and the media janitor purges
|
(`MaintenanceLock`, a heartbeat document; a holder silent for 2 minutes is taken over), and the media janitor purges
|
||||||
@@ -771,6 +785,11 @@ tools/pasture/run.sh down # removes e
|
|||||||
announced by the other), that alice's public post goes to the relays (and through both to Mastodon: Activity-Relay's
|
announced by the other), that alice's public post goes to the relays (and through both to Mastodon: Activity-Relay's
|
||||||
forward on its FEP-8b32 proof, after the scenario makes Mastodon read alice's keys anew) and nothing less public, and
|
forward on its FEP-8b32 proof, after the scenario makes Mastodon read alice's keys anew) and nothing less public, and
|
||||||
has Mastodon leave again, so the town sees no relayed posts. 16 checks.
|
has Mastodon leave again, so the town sees no relayed posts. 16 checks.
|
||||||
|
- **Mailpit (1.31.4, `peers/mailpit.sh`, as `mailpit.test`):** the pasture's mail server. `appsettings.Pasture.json`
|
||||||
|
sends PrivaPub's mail to `pasture-mailpit:1025` (plain SMTP, any login accepted) and its recovery links to
|
||||||
|
`https://decepub.test`. `scenarios/accounts.sh` reads what arrived through Mailpit's API: an address set with the
|
||||||
|
password and its code, recovery only to a verified address and its link working once, logout ending one device,
|
||||||
|
a password change and "sign out everywhere" ending the others, an old hash made again at sign-in, the codes' TTL.
|
||||||
- **Castopod (1.15.5, `peers/castopod.sh`):** the official image on the shared MySQL (database `castopod`, file cache,
|
- **Castopod (1.15.5, `peers/castopod.sh`):** the official image on the shared MySQL (database `castopod`, file cache,
|
||||||
`CP_DISABLE_HTTPS`), its database and superadmin (admin@castopod.test) from `spark`, its REST API switched on in the
|
`CP_DISABLE_HTTPS`), its database and superadmin (admin@castopod.test) from `spark`, its REST API switched on in the
|
||||||
`.env` the image rewrites at each start, `spark fediverse:broadcast` looping in the container. The podcast `@pod` is
|
`.env` the image rewrites at each start, `spark fediverse:broadcast` looping in the container. The podcast `@pod` is
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using PrivaPub.ClientModels.Resources;
|
||||||
|
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace PrivaPub.ClientModels.User
|
||||||
|
{
|
||||||
|
// The login's email address, set only with its password and only once a code sent to it comes back
|
||||||
|
// (/clientapi/user/email, /email/verify).
|
||||||
|
public class EmailChangeForm
|
||||||
|
{
|
||||||
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
EmailAddress(ErrorMessageResourceName = "InvalidEmail", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
StringLength(254, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
Display(Name = "Email", ResourceType = typeof(FieldsNameResource))]
|
||||||
|
public string Email { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
DataType(DataType.Password),
|
||||||
|
PasswordPropertyText(true),
|
||||||
|
StringLength(Constants.MaxPasswordLength, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||||
|
public string Password { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EmailCodeForm
|
||||||
|
{
|
||||||
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
StringLength(16, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
|
||||||
|
public string Code { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// what only the login's password may do (removing its email address)
|
||||||
|
public class PasswordForm
|
||||||
|
{
|
||||||
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
DataType(DataType.Password),
|
||||||
|
PasswordPropertyText(true),
|
||||||
|
StringLength(Constants.MaxPasswordLength, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||||
|
public string Password { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ViewAccount
|
||||||
|
{
|
||||||
|
public string UserName { get; set; }
|
||||||
|
public string Email { get; set; }
|
||||||
|
public bool IsEmailValidated { get; set; } = false;
|
||||||
|
public string PendingEmail { get; set; }//waiting for its code
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,5 +21,8 @@ namespace PrivaPub.ClientModels.User
|
|||||||
public bool ThemeIsDarkMode { get; set; } = false;
|
public bool ThemeIsDarkMode { get; set; } = false;
|
||||||
public bool ThemeIsLightGray { get; set; } = false;
|
public bool ThemeIsLightGray { get; set; } = false;
|
||||||
public bool ThemeIsDarkGray { get; set; } = false;
|
public bool ThemeIsDarkGray { get; set; } = false;
|
||||||
|
|
||||||
|
// when a client last saved these; null until one did, so a client signing in keeps its own until then
|
||||||
|
public DateTime? SavedAt { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,12 +292,19 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
return default;
|
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)]
|
[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();
|
var result = new WebResult();
|
||||||
try
|
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();
|
return Ok();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -307,27 +314,63 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Route("/clientapi/user/update"), Authorize(Policy = Policies.IsUser)]
|
[HttpPost, Route("/clientapi/user/logout/everywhere"), Authorize(Policy = Policies.IsUser)]
|
||||||
public async Task<IActionResult> UpdateUser(UserForm userEmailForm)
|
public async Task<IActionResult> LogoutEverywhere([FromServices] IRootSessions sessions, CancellationToken token)
|
||||||
{
|
{
|
||||||
var result = new WebResult();
|
var result = new WebResult();
|
||||||
if (!ModelState.IsValid)
|
|
||||||
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
result = await UsersService.UpdateUserAsync(userEmailForm, User.GetUserId());
|
await sessions.Revoke(User.GetUserId(), token);
|
||||||
if (!result.IsValid)
|
|
||||||
return StatusCode(result.StatusCode, result);
|
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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."]));
|
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)]
|
[HttpPost, Route("/clientapi/user/update/settings"), Authorize(Policy = Policies.IsUser)]
|
||||||
public async Task<IActionResult> UpdateUserSettings(ViewUserSettings userSettings)
|
public async Task<IActionResult> UpdateUserSettings(ViewUserSettings userSettings)
|
||||||
{
|
{
|
||||||
@@ -361,7 +404,9 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
if (!result.IsValid)
|
if (!result.IsValid)
|
||||||
return StatusCode(result.StatusCode, result);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -500,7 +545,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
if (!result.IsValid)
|
if (!result.IsValid)
|
||||||
return StatusCode(result.StatusCode, result);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ namespace PrivaPub.Infrastructure.Backup
|
|||||||
["Job"] = "work in flight: deliveries and inbox processing belong to the moment",
|
["Job"] = "work in flight: deliveries and inbox processing belong to the moment",
|
||||||
["Delivery"] = "work in flight, from before jobs",
|
["Delivery"] = "work in flight, from before jobs",
|
||||||
["EmailRecovery"] = "recovery codes live an hour",
|
["EmailRecovery"] = "recovery codes live an hour",
|
||||||
|
["EmailVerification"] = "email verification codes live an hour",
|
||||||
["openiddict.tokens"] = "sessions: a restore ends every one",
|
["openiddict.tokens"] = "sessions: a restore ends every one",
|
||||||
["openiddict.authorizations"] = "sessions: a restore ends every one",
|
["openiddict.authorizations"] = "sessions: a restore ends every one",
|
||||||
[nameof(MaintenanceLock)] = "who is backing up or restoring now",
|
[nameof(MaintenanceLock)] = "who is backing up or restoring now",
|
||||||
|
|||||||
@@ -185,6 +185,11 @@ namespace PrivaPub.Infrastructure.Data
|
|||||||
await Plain<InteractionEvent>(token, e => e.Host, e => e.At);
|
await Plain<InteractionEvent>(token, e => e.Host, e => e.At);
|
||||||
await DB.Default.Index<InteractionSalt>().Key(s => s.Day, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
|
await DB.Default.Index<InteractionSalt>().Key(s => s.Day, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
|
||||||
await DB.Default.Index<InteractionSalt>().Key(s => s.ExpiresAt, KeyType.Ascending).Option(o => o.ExpireAfter = TimeSpan.Zero).CreateAsync(token);
|
await DB.Default.Index<InteractionSalt>().Key(s => s.ExpiresAt, KeyType.Ascending).Option(o => o.ExpireAfter = TimeSpan.Zero).CreateAsync(token);
|
||||||
|
// codes live an hour: the TTL monitor removes what expired, nothing else ever has to
|
||||||
|
await DB.Default.Index<EmailRecovery>().Key(r => r.ExpiresAt, KeyType.Ascending).Option(o => o.ExpireAfter = TimeSpan.Zero).CreateAsync(token);
|
||||||
|
await Plain<EmailRecovery>(token, r => r.CodeHash);
|
||||||
|
await DB.Default.Index<EmailVerification>().Key(v => v.ExpiresAt, KeyType.Ascending).Option(o => o.ExpireAfter = TimeSpan.Zero).CreateAsync(token);
|
||||||
|
await Plain<EmailVerification>(token, v => v.RootUserId);
|
||||||
await DB.Default.Index<InstanceDay>().Key(d => d.Day, KeyType.Ascending).Key(d => d.Host, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
|
await DB.Default.Index<InstanceDay>().Key(d => d.Day, KeyType.Ascending).Key(d => d.Host, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
|
||||||
await Plain<InstanceDay>(token, d => d.Host, d => d.Day);
|
await Plain<InstanceDay>(token, d => d.Host, d => d.Day);
|
||||||
await DB.Default.Index<ServerDay>().Key(d => d.Day, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
|
await DB.Default.Index<ServerDay>().Key(d => d.Day, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
|
||||||
|
namespace PrivaPub.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
// recovery writes only to a verified address since 2026-10-17; an address a login gave before verification existed is
|
||||||
|
// taken as verified, or that login would silently lose its way back
|
||||||
|
public class _019_addresses_given_before_verification_are_kept : IMigration
|
||||||
|
{
|
||||||
|
public async Task UpgradeAsync()
|
||||||
|
{
|
||||||
|
await DB.Default.Update<RootUser>()
|
||||||
|
.Match(u => u.Email != null && u.Email != "" && u.DeletedAt == null && !u.IsEmailValidated)
|
||||||
|
.Modify(u => u.IsEmailValidated, true)
|
||||||
|
.ExecuteAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,8 @@ namespace PrivaPub.Middleware
|
|||||||
return service
|
return service
|
||||||
.Configure<MongoSettings>(configuration.GetSection(nameof(MongoSettings)))
|
.Configure<MongoSettings>(configuration.GetSection(nameof(MongoSettings)))
|
||||||
.Configure<AppConfiguration>(configuration.GetSection(nameof(AppConfiguration)))
|
.Configure<AppConfiguration>(configuration.GetSection(nameof(AppConfiguration)))
|
||||||
.Configure<PrivaPub.Infrastructure.RegistrationOptions>(configuration.GetSection("Registrations"));
|
.Configure<PrivaPub.Infrastructure.RegistrationOptions>(configuration.GetSection("Registrations"))
|
||||||
|
.Configure<HashingOptions>(configuration.GetSection("AppConfiguration:HashingOptions"));//PasswordHasher's; unbound before 2026-10-17
|
||||||
}
|
}
|
||||||
public static IServiceCollection PrivaPubFederationConfiguration(this IServiceCollection service, IConfiguration configuration)
|
public static IServiceCollection PrivaPubFederationConfiguration(this IServiceCollection service, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
@@ -111,7 +112,9 @@ namespace PrivaPub.Middleware
|
|||||||
.AddSingleton<IInteractionApprovals, InteractionApprovals>()
|
.AddSingleton<IInteractionApprovals, InteractionApprovals>()
|
||||||
.AddSingleton<IParticipations, Participations>()
|
.AddSingleton<IParticipations, Participations>()
|
||||||
.AddSingleton<IJobHandler, PollRefreshJob>()
|
.AddSingleton<IJobHandler, PollRefreshJob>()
|
||||||
|
.AddSingleton<IMailer, Mailer>()
|
||||||
.AddSingleton<IJobHandler, RecoveryJob>()
|
.AddSingleton<IJobHandler, RecoveryJob>()
|
||||||
|
.AddSingleton<IJobHandler, EmailVerificationJob>()
|
||||||
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
|
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
|
||||||
.AddSingleton<IJobHandler, Domain.Social.FeedSyncJob>()
|
.AddSingleton<IJobHandler, Domain.Social.FeedSyncJob>()
|
||||||
.AddSingleton<IJobHandler, Federation.Actors.OutboxBackfill>()
|
.AddSingleton<IJobHandler, Federation.Actors.OutboxBackfill>()
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ namespace PrivaPub.Models.Jobs
|
|||||||
ProcessMedia,
|
ProcessMedia,
|
||||||
ExportArchive,
|
ExportArchive,
|
||||||
ImportArchive,
|
ImportArchive,
|
||||||
SyncFeeds
|
SyncFeeds,
|
||||||
|
SendEmailVerification
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum JobState
|
public enum JobState
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
namespace PrivaPub.Models.User
|
||||||
|
{
|
||||||
|
// An address a login asked for, until the code sent to it comes back. EmailVerificationJob makes the code and keeps its
|
||||||
|
// hash only; the row expires (a TTL index on ExpiresAt), and five wrong codes take it away.
|
||||||
|
public class EmailVerification : Entity
|
||||||
|
{
|
||||||
|
public const int MaxAttempts = 5;
|
||||||
|
public static readonly TimeSpan Lifetime = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
|
public string RootUserId { get; set; }
|
||||||
|
public string Email { get; set; }
|
||||||
|
public string CodeHash { get; set; }//null until the job sent the code
|
||||||
|
public int Attempts { get; set; }
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ namespace PrivaPub.Models.User
|
|||||||
public DateTime? DeletedAt { get; set; }
|
public DateTime? DeletedAt { get; set; }
|
||||||
public DateTime? CredentialsChangedAt { get; set; }//when RootSessions last ended every session
|
public DateTime? CredentialsChangedAt { get; set; }//when RootSessions last ended every session
|
||||||
public string SessionStamp { get; set; }//carried by every /clientapi token; a new one ends the tokens issued before
|
public string SessionStamp { get; set; }//carried by every /clientapi token; a new one ends the tokens issued before
|
||||||
|
public List<EndedSession> EndedSessions { get; set; } = new();//sessions signed out one by one, until their tokens expire
|
||||||
|
|
||||||
/// <summary>An administrator is a moderator too (IsAdminPolicy asks for both); otherwise a plain user.</summary>
|
/// <summary>An administrator is a moderator too (IsAdminPolicy asks for both); otherwise a plain user.</summary>
|
||||||
public void SetAdmin(bool admin)
|
public void SetAdmin(bool admin)
|
||||||
@@ -34,9 +35,17 @@ namespace PrivaPub.Models.User
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a session that signed out: its tokens carry Sid, and are refused until Until, when they would have expired anyway
|
||||||
|
public class EndedSession
|
||||||
|
{
|
||||||
|
public string Sid { get; set; }
|
||||||
|
public DateTime Until { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class RootUserSettings
|
public class RootUserSettings
|
||||||
{
|
{
|
||||||
public string LanguageCode { get; set; } = "en-GB";
|
public string LanguageCode { get; set; } = "en-GB";
|
||||||
|
public DateTime? SavedAt { get; set; }//when a client last saved them; until then a client keeps its own
|
||||||
|
|
||||||
public short IconsThemeIndexColour { get; set; } = 25;
|
public short IconsThemeIndexColour { get; set; } = 25;
|
||||||
public short LightThemeIndexColour { get; set; } = 25;
|
public short LightThemeIndexColour { get; set; } = 25;
|
||||||
|
|||||||
@@ -1,36 +1,19 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
using MongoDB.Entities;
|
|
||||||
|
|
||||||
using PrivaPub.Models;
|
using PrivaPub.Models;
|
||||||
using PrivaPub.StaticServices;
|
|
||||||
|
|
||||||
namespace PrivaPub.Services
|
namespace PrivaPub.Services
|
||||||
{
|
{
|
||||||
|
// The configuration as appsettings has it now. Builds before 2026-10-17 copied it into Mongo on first boot and read that
|
||||||
|
// copy forever after, so a changed SMTP account or language list never took; the copy is no longer read.
|
||||||
public class AppConfigurationService
|
public class AppConfigurationService
|
||||||
{
|
{
|
||||||
public AppConfiguration AppConfiguration;
|
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
|
||||||
readonly DbEntities _dbEntities;
|
|
||||||
AppConfiguration _appConfigurationFromFile;
|
|
||||||
public AppConfigurationService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
|
|
||||||
{
|
|
||||||
_dbEntities = dbEntities;
|
|
||||||
_appConfigurationFromFile = appConfiguration.CurrentValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Init()
|
public AppConfigurationService(IOptionsMonitor<AppConfiguration> appConfiguration) => _appConfiguration = appConfiguration;
|
||||||
{
|
|
||||||
var dbAppConfiguration = await _dbEntities.AppConfiguration.ExecuteFirstAsync();
|
|
||||||
if (dbAppConfiguration != default)
|
|
||||||
{
|
|
||||||
if (AppConfiguration == default || AppConfiguration.LastUpdateDate != dbAppConfiguration.LastUpdateDate)
|
|
||||||
AppConfiguration = dbAppConfiguration;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dbAppConfiguration = _appConfigurationFromFile;
|
public AppConfiguration AppConfiguration => _appConfiguration.CurrentValue;
|
||||||
await DB.Default.SaveAsync(dbAppConfiguration);
|
|
||||||
AppConfiguration = dbAppConfiguration;
|
public Task Init() => Task.CompletedTask;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
|
using PrivaPub.Models.Jobs;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
using PrivaPub.Resources;
|
||||||
|
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PrivaPub.Services
|
||||||
|
{
|
||||||
|
public sealed record EmailVerificationPayload(string VerificationId);
|
||||||
|
|
||||||
|
// Sends the code that proves a login owns the address it asked for. The code exists only in the email; the row keeps its
|
||||||
|
// hash. An address another login has verified meanwhile gets nothing, so the answer to the request never told whether
|
||||||
|
// the address was taken, and no stranger's inbox receives a code it didn't ask for twice.
|
||||||
|
public class EmailVerificationJob : IJobHandler
|
||||||
|
{
|
||||||
|
public const string Host = "email";
|
||||||
|
|
||||||
|
readonly IMailer _mailer;
|
||||||
|
readonly IStringLocalizer<GenericRes> _localizer;
|
||||||
|
readonly ILogger<EmailVerificationJob> _logger;
|
||||||
|
|
||||||
|
public EmailVerificationJob(IMailer mailer, IStringLocalizer<GenericRes> localizer, ILogger<EmailVerificationJob> logger)
|
||||||
|
{
|
||||||
|
_mailer = mailer;
|
||||||
|
_localizer = localizer;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JobKind Kind => JobKind.SendEmailVerification;
|
||||||
|
public int Concurrency => 1;
|
||||||
|
public int MaxAttempts => 3;
|
||||||
|
public int PerHostLimit => 1;
|
||||||
|
|
||||||
|
public static string NewCode() => RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6");
|
||||||
|
|
||||||
|
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
||||||
|
{
|
||||||
|
var payload = JsonSerializer.Deserialize<EmailVerificationPayload>(job.Payload);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var pending = string.IsNullOrEmpty(payload?.VerificationId) ? default
|
||||||
|
: await DB.Default.Find<EmailVerification>().Match(v => v.ID == payload.VerificationId && v.ExpiresAt > now).ExecuteFirstAsync(token);
|
||||||
|
if (pending == default)
|
||||||
|
return JobOutcome.Done;
|
||||||
|
var user = await DB.Default.Find<RootUser>().MatchID(pending.RootUserId).ExecuteFirstAsync(token);
|
||||||
|
if (user is not { DeletedAt: null, IsBanned: false })
|
||||||
|
return JobOutcome.Done;
|
||||||
|
if (await DB.Default.Find<RootUser>().Match(u => u.Email == pending.Email && u.IsEmailValidated && u.ID != user.ID && u.DeletedAt == null).ExecuteAnyAsync(token))
|
||||||
|
return JobOutcome.Done;
|
||||||
|
|
||||||
|
var code = NewCode();
|
||||||
|
await DB.Default.Update<EmailVerification>().MatchID(pending.ID).Modify(v => v.CodeHash, RecoveryJob.Hash(code)).ExecuteAsync(token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _mailer.Send(pending.Email, _localizer["PrivaPub - Confirm your email address"],
|
||||||
|
string.Format(_localizer[
|
||||||
|
"Hello,\n\nThe PrivaPub login {0} asked to use this address. If it was you, enter this code within an hour:\n\n{1}\n\nIf it was not you, ignore this email: the address is not used."],
|
||||||
|
user.UserName, code), token);
|
||||||
|
return JobOutcome.Done;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException || !token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "The verification email could not be sent");
|
||||||
|
return JobOutcome.Retry("smtp");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,6 @@ namespace PrivaPub.Services
|
|||||||
Task<WebResult> RemoveUserAsync(UsersIds usersIds);
|
Task<WebResult> RemoveUserAsync(UsersIds usersIds);
|
||||||
Task<WebResult> RemoveSelfAsync(string rootId, string password);
|
Task<WebResult> RemoveSelfAsync(string rootId, string password);
|
||||||
|
|
||||||
Task<WebResult> UpdateUserAsync(UserForm userEmailForm, string userId);
|
|
||||||
|
|
||||||
Task<WebResult> UpdateUserPasswordAsync(UserPasswordForm userPasswordForm, string userId);
|
Task<WebResult> UpdateUserPasswordAsync(UserPasswordForm userPasswordForm, string userId);
|
||||||
|
|
||||||
Task<WebResult> GetUserSettingsAsync(string userId, LoginForm loginForm = default);
|
Task<WebResult> GetUserSettingsAsync(string userId, LoginForm loginForm = default);
|
||||||
@@ -31,5 +29,13 @@ namespace PrivaPub.Services
|
|||||||
Task<WebResult> IsValidRecoveryCode(string recoveryCode);
|
Task<WebResult> IsValidRecoveryCode(string recoveryCode);
|
||||||
|
|
||||||
Task<WebResult> ChangePassword(NewPasswordForm newPasswordForm);
|
Task<WebResult> ChangePassword(NewPasswordForm newPasswordForm);
|
||||||
|
|
||||||
|
Task<WebResult> AccountAsync(string rootId);
|
||||||
|
|
||||||
|
Task<WebResult> RequestEmailChangeAsync(string rootId, EmailChangeForm form);
|
||||||
|
|
||||||
|
Task<WebResult> VerifyEmailAsync(string rootId, string code);
|
||||||
|
|
||||||
|
Task<WebResult> RemoveEmailAsync(string rootId, string password);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using MailKit.Net.Smtp;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
using MimeKit;
|
||||||
|
|
||||||
|
using PrivaPub.Models;
|
||||||
|
|
||||||
|
namespace PrivaPub.Services
|
||||||
|
{
|
||||||
|
public interface IMailer
|
||||||
|
{
|
||||||
|
/// <summary>Sends a plain-text email; throws when SMTP fails, so the job that sends it retries.</summary>
|
||||||
|
Task Send(string to, string subject, string body, CancellationToken token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server's one way to send an email: through the SMTP account in appsettings (AppConfiguration:EmailConfiguration),
|
||||||
|
// read as it is now. Only jobs call it, so nobody waits on SMTP.
|
||||||
|
public class Mailer : IMailer
|
||||||
|
{
|
||||||
|
readonly IOptionsMonitor<AppConfiguration> _app;
|
||||||
|
|
||||||
|
public Mailer(IOptionsMonitor<AppConfiguration> app) => _app = app;
|
||||||
|
|
||||||
|
public async Task Send(string to, string subject, string body, CancellationToken token)
|
||||||
|
{
|
||||||
|
var email = _app.CurrentValue.EmailConfiguration;
|
||||||
|
var message = new MimeMessage();
|
||||||
|
message.From.Add(new MailboxAddress("PrivaPub", email.SmtpUsername));
|
||||||
|
message.To.Add(MailboxAddress.Parse(to));
|
||||||
|
message.Subject = subject;
|
||||||
|
message.Body = new TextPart("plain") { Text = body };
|
||||||
|
using var smtp = new SmtpClient();
|
||||||
|
await smtp.ConnectAsync(email.SmtpServer, email.SmtpPort, email.UseSSL, token);
|
||||||
|
if (!string.IsNullOrEmpty(email.SmtpPassword))
|
||||||
|
await smtp.AuthenticateAsync(email.SmtpUsername, email.SmtpPassword, token);
|
||||||
|
await smtp.SendAsync(message, token);
|
||||||
|
await smtp.DisconnectAsync(quit: true, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
using MailKit.Net.Smtp;
|
|
||||||
|
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
|
|
||||||
using MimeKit;
|
|
||||||
|
|
||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
using PrivaPub.Infrastructure.Jobs;
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
@@ -21,19 +17,19 @@ namespace PrivaPub.Services
|
|||||||
|
|
||||||
// Sends a password recovery link. Every request queues one of these, for an account that exists or not, so the answer
|
// Sends a password recovery link. Every request queues one of these, for an account that exists or not, so the answer
|
||||||
// to the request and its timing say nothing; SMTP's slowness and failures happen here, where nobody waits on them.
|
// to the request and its timing say nothing; SMTP's slowness and failures happen here, where nobody waits on them.
|
||||||
// The code exists only in the email: the database keeps its hash, for an hour.
|
// The code exists only in the email: the database keeps its hash, for an hour. Only a verified address is written to.
|
||||||
public class RecoveryJob : IJobHandler
|
public class RecoveryJob : IJobHandler
|
||||||
{
|
{
|
||||||
public const string Host = "recovery";
|
public const string Host = "recovery";
|
||||||
public static readonly TimeSpan CodeLifetime = TimeSpan.FromHours(1);
|
public static readonly TimeSpan CodeLifetime = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
readonly AppConfigurationService _app;
|
readonly IMailer _mailer;
|
||||||
readonly IStringLocalizer<GenericRes> _localizer;
|
readonly IStringLocalizer<GenericRes> _localizer;
|
||||||
readonly ILogger<RecoveryJob> _logger;
|
readonly ILogger<RecoveryJob> _logger;
|
||||||
|
|
||||||
public RecoveryJob(AppConfigurationService app, IStringLocalizer<GenericRes> localizer, ILogger<RecoveryJob> logger)
|
public RecoveryJob(IMailer mailer, IStringLocalizer<GenericRes> localizer, ILogger<RecoveryJob> logger)
|
||||||
{
|
{
|
||||||
_app = app;
|
_mailer = mailer;
|
||||||
_localizer = localizer;
|
_localizer = localizer;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -51,31 +47,18 @@ namespace PrivaPub.Services
|
|||||||
if (string.IsNullOrEmpty(payload?.RootUserId))
|
if (string.IsNullOrEmpty(payload?.RootUserId))
|
||||||
return JobOutcome.Done;//asked for an account that does not exist: there is nobody to write to
|
return JobOutcome.Done;//asked for an account that does not exist: there is nobody to write to
|
||||||
var user = await DB.Default.Find<RootUser>().MatchID(payload.RootUserId).ExecuteFirstAsync(token);
|
var user = await DB.Default.Find<RootUser>().MatchID(payload.RootUserId).ExecuteFirstAsync(token);
|
||||||
if (user is not { DeletedAt: null, IsBanned: false } || string.IsNullOrEmpty(user.Email))
|
if (user is not { DeletedAt: null, IsBanned: false, IsEmailValidated: true } || string.IsNullOrEmpty(user.Email))
|
||||||
return JobOutcome.Done;
|
return JobOutcome.Done;
|
||||||
|
|
||||||
var code = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
|
var code = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
|
||||||
await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == user.ID);
|
await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == user.ID);
|
||||||
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = user.ID, CodeHash = Hash(code), ExpiresAt = DateTime.UtcNow + CodeLifetime }, token);
|
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = user.ID, CodeHash = Hash(code), ExpiresAt = DateTime.UtcNow + CodeLifetime }, token);
|
||||||
|
|
||||||
var email = _app.AppConfiguration.EmailConfiguration;
|
|
||||||
var message = new MimeMessage();
|
|
||||||
message.From.Add(new MailboxAddress("PrivaPub", email.SmtpUsername));
|
|
||||||
message.To.Add(MailboxAddress.Parse(user.Email));
|
|
||||||
message.Subject = _localizer["PrivaPub - Password recovery link"];
|
|
||||||
message.Body = new TextPart("plain")
|
|
||||||
{
|
|
||||||
Text = string.Format(_localizer[
|
|
||||||
"Hello,\n\nSomeone asked to reset the password of the PrivaPub login {0}. If it was you, open this link within an hour:\n{1}\n\nIf it was not you, ignore this email: nothing changes."],
|
|
||||||
user.UserName, $"{payload.Host}/password-recovery?rc={code}")
|
|
||||||
};
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var smtp = new SmtpClient();
|
await _mailer.Send(user.Email, _localizer["PrivaPub - Password recovery link"],
|
||||||
await smtp.ConnectAsync(email.SmtpServer, email.SmtpPort, email.UseSSL, token);
|
string.Format(_localizer[
|
||||||
await smtp.AuthenticateAsync(email.SmtpUsername, email.SmtpPassword, token);
|
"Hello,\n\nSomeone asked to reset the password of the PrivaPub login {0}. If it was you, open this link within an hour:\n{1}\n\nIf it was not you, ignore this email: nothing changes."],
|
||||||
await smtp.SendAsync(message, token);
|
user.UserName, $"{payload.Host}/password-recovery?rc={code}"), token);
|
||||||
await smtp.DisconnectAsync(quit: true, token);
|
|
||||||
return JobOutcome.Done;
|
return JobOutcome.Done;
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException || !token.IsCancellationRequested)
|
catch (Exception ex) when (ex is not OperationCanceledException || !token.IsCancellationRequested)
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ namespace PrivaPub.Services
|
|||||||
return "The account can no longer be used.";
|
return "The account can no longer be used.";
|
||||||
if ((root.SessionStamp ?? string.Empty) != (principal.FindFirst(AuthTokenManager.SessionStamp)?.Value ?? string.Empty))
|
if ((root.SessionStamp ?? string.Empty) != (principal.FindFirst(AuthTokenManager.SessionStamp)?.Value ?? string.Empty))
|
||||||
return "The account's sessions were ended.";
|
return "The account's sessions were ended.";
|
||||||
|
var sessionId = principal.FindFirst(AuthTokenManager.SessionId)?.Value;
|
||||||
|
if (!string.IsNullOrEmpty(sessionId) && root.EndedSessions.Any(s => s.Sid == sessionId))
|
||||||
|
return "The session was signed out.";
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
using OpenIddict.Abstractions;
|
using OpenIddict.Abstractions;
|
||||||
@@ -10,22 +11,26 @@ namespace PrivaPub.Services
|
|||||||
public interface IRootSessions
|
public interface IRootSessions
|
||||||
{
|
{
|
||||||
Task Revoke(string rootId, CancellationToken token);
|
Task Revoke(string rootId, CancellationToken token);
|
||||||
|
Task End(string rootId, string sessionId, CancellationToken token);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ends everything a root is signed in with: its /clientapi tokens, through a new SessionStamp (checked by JwtEvents),
|
// Ends everything a root is signed in with: its /clientapi tokens, through a new SessionStamp (checked by JwtEvents),
|
||||||
// and the Mastodon API tokens and authorizations of each of its personas. Used when its password is recovered and when
|
// and the Mastodon API tokens and authorizations of each of its personas. Used when its password is changed or
|
||||||
// it is deleted.
|
// recovered, when it signs out everywhere and when it is deleted. End signs out one session only: the device that
|
||||||
|
// asked (its Mastodon tokens it revokes itself).
|
||||||
public class RootSessions : IRootSessions
|
public class RootSessions : IRootSessions
|
||||||
{
|
{
|
||||||
readonly DbEntities _dbEntities;
|
readonly DbEntities _dbEntities;
|
||||||
readonly IOpenIddictTokenManager _tokens;
|
readonly IOpenIddictTokenManager _tokens;
|
||||||
readonly IOpenIddictAuthorizationManager _authorizations;
|
readonly IOpenIddictAuthorizationManager _authorizations;
|
||||||
|
readonly AuthTokenManager _authTokens;
|
||||||
|
|
||||||
public RootSessions(DbEntities dbEntities, IOpenIddictTokenManager tokens, IOpenIddictAuthorizationManager authorizations)
|
public RootSessions(DbEntities dbEntities, IOpenIddictTokenManager tokens, IOpenIddictAuthorizationManager authorizations, AuthTokenManager authTokens)
|
||||||
{
|
{
|
||||||
_dbEntities = dbEntities;
|
_dbEntities = dbEntities;
|
||||||
_tokens = tokens;
|
_tokens = tokens;
|
||||||
_authorizations = authorizations;
|
_authorizations = authorizations;
|
||||||
|
_authTokens = authTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Revoke(string rootId, CancellationToken token)
|
public async Task Revoke(string rootId, CancellationToken token)
|
||||||
@@ -33,6 +38,7 @@ namespace PrivaPub.Services
|
|||||||
await DB.Default.Update<RootUser>().MatchID(rootId)
|
await DB.Default.Update<RootUser>().MatchID(rootId)
|
||||||
.Modify(u => u.CredentialsChangedAt, DateTime.UtcNow)
|
.Modify(u => u.CredentialsChangedAt, DateTime.UtcNow)
|
||||||
.Modify(u => u.SessionStamp, Guid.NewGuid().ToString("N"))
|
.Modify(u => u.SessionStamp, Guid.NewGuid().ToString("N"))
|
||||||
|
.Modify(u => u.EndedSessions, new List<EndedSession>())//the new stamp ends those too
|
||||||
.ExecuteAsync(token);
|
.ExecuteAsync(token);
|
||||||
foreach (var link in await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId).ExecuteAsync(token))
|
foreach (var link in await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId).ExecuteAsync(token))
|
||||||
{
|
{
|
||||||
@@ -40,5 +46,17 @@ namespace PrivaPub.Services
|
|||||||
await _authorizations.RevokeBySubjectAsync(link.AvatarId, token);
|
await _authorizations.RevokeBySubjectAsync(link.AvatarId, token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// remembered as long as one of its tokens could still be valid; the ones that have expired are forgotten meanwhile
|
||||||
|
public async Task End(string rootId, string sessionId, CancellationToken token)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
await DB.Default.Update<RootUser>().MatchID(rootId)
|
||||||
|
.Modify(b => b.PullFilter(u => u.EndedSessions, s => s.Until < now))
|
||||||
|
.ExecuteAsync(token);
|
||||||
|
await DB.Default.Update<RootUser>().MatchID(rootId)
|
||||||
|
.Modify(b => b.Push(u => u.EndedSessions, new EndedSession { Sid = sessionId, Until = now + _authTokens.Lifetime }))
|
||||||
|
.ExecuteAsync(token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,8 +114,16 @@ namespace PrivaPub.Services
|
|||||||
if (user.IsBanned)
|
if (user.IsBanned)
|
||||||
return result.Invalidate(Localizer["User '{0}' banned.", user.UserName]);
|
return result.Invalidate(Localizer["User '{0}' banned.", user.UserName]);
|
||||||
|
|
||||||
|
// hashed again at today's iterations; matched on the old hash, so two sign-ins at once never undo each other
|
||||||
if (needsUpgrade)
|
if (needsUpgrade)
|
||||||
result.ErrorMessage = Localizer["Needs upgrade!"];
|
{
|
||||||
|
var oldHash = user.HashedPassword;
|
||||||
|
user.HashedPassword = PasswordHasher.Hash(loginForm.Password);
|
||||||
|
await DB.Default.Update<RootUser>()
|
||||||
|
.Match(u => u.ID == user.ID && u.HashedPassword == oldHash)
|
||||||
|
.Modify(u => u.HashedPassword, user.HashedPassword)
|
||||||
|
.ExecuteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
var userSettingsResult = await GetUserSettingsAsync(user.ID, loginForm);
|
var userSettingsResult = await GetUserSettingsAsync(user.ID, loginForm);
|
||||||
var userSettings = (ViewUserSettings)userSettingsResult.Data;
|
var userSettings = (ViewUserSettings)userSettingsResult.Data;
|
||||||
@@ -131,33 +139,6 @@ namespace PrivaPub.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WebResult> UpdateUserAsync(UserForm userForm, string userId)
|
|
||||||
{
|
|
||||||
var result = new WebResult();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var currentUser = await DbEntities.RootUsers.Match(u => u.ID == userId).ExecuteFirstAsync();
|
|
||||||
if (!string.IsNullOrEmpty(userForm.Email) && currentUser.Email != userForm.Email)
|
|
||||||
{
|
|
||||||
var emailAlreadyUsed = await DbEntities.RootUsers.Match(u => u.Email == userForm.Email).ExecuteAnyAsync();
|
|
||||||
if (emailAlreadyUsed)
|
|
||||||
return result.Invalidate(Localizer["Email '{0}' already taken.", userForm.Email]);
|
|
||||||
}
|
|
||||||
|
|
||||||
await DB.Default.Update<RootUser>()
|
|
||||||
.Match(u => u.ID == userId)
|
|
||||||
.Modify(u => u.Email, userForm.Email)
|
|
||||||
.ExecuteAsync();
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserAsync)}()");
|
|
||||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<WebResult> UpdateUserSettingsAsync(ViewUserSettings userSettings, string userId)
|
public async Task<WebResult> UpdateUserSettingsAsync(ViewUserSettings userSettings, string userId)
|
||||||
{
|
{
|
||||||
var result = new WebResult();
|
var result = new WebResult();
|
||||||
@@ -184,6 +165,7 @@ namespace PrivaPub.Services
|
|||||||
.Modify(u => u.Settings.ThemeIsDarkGray, userSettings.ThemeIsDarkGray)
|
.Modify(u => u.Settings.ThemeIsDarkGray, userSettings.ThemeIsDarkGray)
|
||||||
.Modify(u => u.Settings.ThemeIsLightGray, userSettings.ThemeIsLightGray)
|
.Modify(u => u.Settings.ThemeIsLightGray, userSettings.ThemeIsLightGray)
|
||||||
.Modify(u => u.Settings.IconsThemeIndexColour, userSettings.IconsThemeIndexColour)
|
.Modify(u => u.Settings.IconsThemeIndexColour, userSettings.IconsThemeIndexColour)
|
||||||
|
.Modify(u => u.Settings.SavedAt, DateTime.UtcNow)
|
||||||
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
|
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
|
||||||
.ExecuteAsync();
|
.ExecuteAsync();
|
||||||
|
|
||||||
@@ -211,11 +193,14 @@ namespace PrivaPub.Services
|
|||||||
if (!verified)
|
if (!verified)
|
||||||
return result.Invalidate(Localizer["Wrong password."]);
|
return result.Invalidate(Localizer["Wrong password."]);
|
||||||
|
|
||||||
var newPasswordHashed = PasswordHasher.Hash(userPasswordForm.NewPassword);
|
await DB.Default.Update<RootUser>().MatchID(user.ID)
|
||||||
user.HashedPassword = newPasswordHashed;
|
.Modify(u => u.HashedPassword, PasswordHasher.Hash(userPasswordForm.NewPassword))
|
||||||
user.UpdatedAt = DateTime.UtcNow;
|
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
|
||||||
await DB.Default.SaveAsync(user);
|
.ExecuteAsync();
|
||||||
|
// whoever else is signed in may be why the password changed: every session ends, and the caller gets a new one
|
||||||
|
await Sessions.Revoke(user.ID, CancellationToken.None);
|
||||||
|
user = await DbEntities.RootUsers.MatchID(user.ID).ExecuteFirstAsync();
|
||||||
|
result.Data = (user, ToViewSettings(user.Settings));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -343,7 +328,7 @@ namespace PrivaPub.Services
|
|||||||
var userName = passwordRecoveryForm.UserName?.ToLowerInvariant();
|
var userName = passwordRecoveryForm.UserName?.ToLowerInvariant();
|
||||||
var user = passwordRecoveryForm.IsEmailDisabled
|
var user = passwordRecoveryForm.IsEmailDisabled
|
||||||
? await DbEntities.RootUsers.Match(u => u.UserName == userName && u.DeletedAt == null).ExecuteFirstAsync()
|
? await DbEntities.RootUsers.Match(u => u.UserName == userName && u.DeletedAt == null).ExecuteFirstAsync()
|
||||||
: await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.DeletedAt == null).ExecuteFirstAsync();
|
: await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.IsEmailValidated && u.DeletedAt == null).ExecuteFirstAsync();
|
||||||
await Jobs.Enqueue(JobKind.SendRecovery, JsonSerializer.Serialize(new RecoveryPayload(user?.ID, host)), RecoveryJob.Host,
|
await Jobs.Enqueue(JobKind.SendRecovery, JsonSerializer.Serialize(new RecoveryPayload(user?.ID, host)), RecoveryJob.Host,
|
||||||
dedupeKey: default, CancellationToken.None);
|
dedupeKey: default, CancellationToken.None);
|
||||||
result.Data = Localizer[RecoverySent].Value;
|
result.Data = Localizer[RecoverySent].Value;
|
||||||
@@ -413,7 +398,132 @@ namespace PrivaPub.Services
|
|||||||
ThemeIsDarkMode = settings.ThemeIsDarkMode,
|
ThemeIsDarkMode = settings.ThemeIsDarkMode,
|
||||||
IconsThemeIndexColour = settings.IconsThemeIndexColour,
|
IconsThemeIndexColour = settings.IconsThemeIndexColour,
|
||||||
ThemeIsDarkGray = settings.ThemeIsDarkGray,
|
ThemeIsDarkGray = settings.ThemeIsDarkGray,
|
||||||
ThemeIsLightGray = settings.ThemeIsLightGray
|
ThemeIsLightGray = settings.ThemeIsLightGray,
|
||||||
|
SavedAt = settings.SavedAt
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public const string EmailCodeSent = "If that address can be used, a code is on its way to it. It works for one hour.";
|
||||||
|
public const string EmailCodeInvalid = "That code is not valid.";
|
||||||
|
|
||||||
|
public async Task<WebResult> AccountAsync(string rootId)
|
||||||
|
{
|
||||||
|
var result = new WebResult();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
|
||||||
|
if (user == default)
|
||||||
|
return result.Invalidate(Localizer["User not found."], StatusCodes.Status404NotFound);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var pending = await DB.Default.Find<EmailVerification>().Match(v => v.RootUserId == rootId && v.ExpiresAt > now).ExecuteFirstAsync();
|
||||||
|
result.Data = new ViewAccount
|
||||||
|
{
|
||||||
|
UserName = user.UserName,
|
||||||
|
Email = user.Email,
|
||||||
|
IsEmailValidated = user.IsEmailValidated,
|
||||||
|
PendingEmail = pending?.Email,
|
||||||
|
CreatedAt = user.CreatedAt
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(AccountAsync)}()");
|
||||||
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only with the password, so a stolen session can't move recovery to its own inbox. The answer is the same sentence
|
||||||
|
// whether or not the address is free; EmailVerificationJob sends the code, or nothing when another login holds it.
|
||||||
|
public async Task<WebResult> RequestEmailChangeAsync(string rootId, EmailChangeForm form)
|
||||||
|
{
|
||||||
|
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, form.Password ?? string.Empty);
|
||||||
|
if (user?.HashedPassword == default || !verified)
|
||||||
|
return result.Invalidate(Localizer["Wrong password."], StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
await DB.Default.DeleteAsync<EmailVerification>(v => v.RootUserId == user.ID);
|
||||||
|
var pending = new EmailVerification
|
||||||
|
{
|
||||||
|
RootUserId = user.ID,
|
||||||
|
Email = form.Email.Trim().ToLowerInvariant(),
|
||||||
|
ExpiresAt = DateTime.UtcNow + EmailVerification.Lifetime
|
||||||
|
};
|
||||||
|
await DB.Default.SaveAsync(pending);
|
||||||
|
await Jobs.Enqueue(JobKind.SendEmailVerification, JsonSerializer.Serialize(new EmailVerificationPayload(pending.ID)),
|
||||||
|
EmailVerificationJob.Host, dedupeKey: default, CancellationToken.None);
|
||||||
|
result.Data = Localizer[EmailCodeSent].Value;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RequestEmailChangeAsync)}()");
|
||||||
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One answer for every failure (wrong, expired, five tries spent, or the address verified by another login meanwhile)
|
||||||
|
public async Task<WebResult> VerifyEmailAsync(string rootId, string code)
|
||||||
|
{
|
||||||
|
var result = new WebResult();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var pending = await DB.Default.Find<EmailVerification>().Match(v => v.RootUserId == rootId && v.ExpiresAt > now).ExecuteFirstAsync();
|
||||||
|
if (pending == default || string.IsNullOrEmpty(pending.CodeHash))
|
||||||
|
return result.Invalidate(Localizer[EmailCodeInvalid]);
|
||||||
|
if (pending.CodeHash != RecoveryJob.Hash(code?.Trim()))
|
||||||
|
{
|
||||||
|
if (pending.Attempts + 1 >= EmailVerification.MaxAttempts)
|
||||||
|
await DB.Default.DeleteAsync<EmailVerification>(pending.ID);
|
||||||
|
else
|
||||||
|
await DB.Default.Update<EmailVerification>().MatchID(pending.ID).Modify(b => b.Inc(v => v.Attempts, 1)).ExecuteAsync();
|
||||||
|
return result.Invalidate(Localizer[EmailCodeInvalid]);
|
||||||
|
}
|
||||||
|
await DB.Default.DeleteAsync<EmailVerification>(pending.ID);
|
||||||
|
if (await DbEntities.RootUsers.Match(u => u.Email == pending.Email && u.IsEmailValidated && u.ID != rootId && u.DeletedAt == null).ExecuteAnyAsync())
|
||||||
|
return result.Invalidate(Localizer[EmailCodeInvalid]);
|
||||||
|
|
||||||
|
await DB.Default.Update<RootUser>().MatchID(rootId)
|
||||||
|
.Modify(u => u.Email, pending.Email)
|
||||||
|
.Modify(u => u.IsEmailValidated, true)
|
||||||
|
.Modify(u => u.UpdatedAt, now)
|
||||||
|
.ExecuteAsync();
|
||||||
|
return await AccountAsync(rootId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(VerifyEmailAsync)}()");
|
||||||
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> RemoveEmailAsync(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);
|
||||||
|
|
||||||
|
await DB.Default.Update<RootUser>().MatchID(rootId)
|
||||||
|
.Modify(u => u.Email, null)
|
||||||
|
.Modify(u => u.IsEmailValidated, false)
|
||||||
|
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
|
||||||
|
.ExecuteAsync();
|
||||||
|
await DB.Default.DeleteAsync<EmailVerification>(v => v.RootUserId == rootId);
|
||||||
|
await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == rootId);
|
||||||
|
return await AccountAsync(rootId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveEmailAsync)}()");
|
||||||
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,9 @@ namespace PrivaPub.StaticServices
|
|||||||
{
|
{
|
||||||
public const string SessionStamp = "ss";
|
public const string SessionStamp = "ss";
|
||||||
const string SessionStampClaim = SessionStamp;//the root's RootUser.SessionStamp when issued; JwtEvents refuses a stale one
|
const string SessionStampClaim = SessionStamp;//the root's RootUser.SessionStamp when issued; JwtEvents refuses a stale one
|
||||||
|
// one sign-in on one device: its tokens, renewals included, carry the same id, so signing out ends that device alone
|
||||||
|
// (RootUser.EndedSessions). Not "sid", which JwtBearer maps to another claim type on the way in.
|
||||||
|
public const string SessionId = "si";
|
||||||
|
|
||||||
readonly IConfiguration Configuration;
|
readonly IConfiguration Configuration;
|
||||||
|
|
||||||
@@ -22,9 +25,12 @@ namespace PrivaPub.StaticServices
|
|||||||
Configuration = configuration;
|
Configuration = configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
public JwtUser GenerateToken(RootUser user, ViewUserSettings userSettings)
|
public TimeSpan Lifetime => TimeSpan.FromHours(int.Parse(Configuration["AppConfiguration:Jwt:HoursTimeout"]));
|
||||||
|
|
||||||
|
/// <summary>A token for the root; a renewal passes the session it renews, a new sign-in gets a new one.</summary>
|
||||||
|
public JwtUser GenerateToken(RootUser user, ViewUserSettings userSettings, string sessionId = default)
|
||||||
{
|
{
|
||||||
var expiration = DateTime.UtcNow.AddHours(int.Parse(Configuration["AppConfiguration:Jwt:HoursTimeout"]));
|
var expiration = DateTime.UtcNow + Lifetime;
|
||||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["AppConfiguration:Jwt:Key"]));
|
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["AppConfiguration:Jwt:Key"]));
|
||||||
var jwtUser = new JwtUser
|
var jwtUser = new JwtUser
|
||||||
{
|
{
|
||||||
@@ -39,7 +45,8 @@ namespace PrivaPub.StaticServices
|
|||||||
{
|
{
|
||||||
new(ClaimTypes.UserData, user.ID),
|
new(ClaimTypes.UserData, user.ID),
|
||||||
new(ClaimTypes.Name, user.UserName),
|
new(ClaimTypes.Name, user.UserName),
|
||||||
new(SessionStampClaim, user.SessionStamp ?? string.Empty)
|
new(SessionStampClaim, user.SessionStamp ?? string.Empty),
|
||||||
|
new(SessionId, string.IsNullOrEmpty(sessionId) ? Guid.NewGuid().ToString("N") : sessionId)
|
||||||
};
|
};
|
||||||
|
|
||||||
claims.Add(new(Policies.IsUser, $"{user.Policies.Contains(Policies.IsUser)}".ToLower()));
|
claims.Add(new(Policies.IsUser, $"{user.Policies.Contains(Policies.IsUser)}".ToLower()));
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
"SmtpPassword": "c4kXUJFQeKC2dVQbZqxZ"
|
"SmtpPassword": "c4kXUJFQeKC2dVQbZqxZ"
|
||||||
},
|
},
|
||||||
"HashingOptions": {
|
"HashingOptions": {
|
||||||
"Iterations": 10101
|
"Iterations": 210000
|
||||||
},
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Key": "bbLOwQyh0jYtJ8Dn6WKCneydLJkqeElqUM03Qwtr7pEMyq/qCZD6v/Sh5Kwrq6YpvJm8GSTHfBiCajUmicfgKw==",
|
"Key": "bbLOwQyh0jYtJ8Dn6WKCneydLJkqeElqUM03Qwtr7pEMyq/qCZD6v/Sh5Kwrq6YpvJm8GSTHfBiCajUmicfgKw==",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
"SmtpPassword": "c4kXUJFQeKC2dVQbZqxZ"
|
"SmtpPassword": "c4kXUJFQeKC2dVQbZqxZ"
|
||||||
},
|
},
|
||||||
"HashingOptions": {
|
"HashingOptions": {
|
||||||
"Iterations": 10101
|
"Iterations": 210000
|
||||||
},
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Key": "FVkEHw8t84HVXE/+3fyY6gH1AJIlLVNE9UqOzFNBdRKsdFuKrCFBvys0IQfZu+d2Qgg6KtslgntiedPMO98iKg==",
|
"Key": "FVkEHw8t84HVXE/+3fyY6gH1AJIlLVNE9UqOzFNBdRKsdFuKrCFBvys0IQfZu+d2Qgg6KtslgntiedPMO98iKg==",
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ decepub.test {
|
|||||||
reverse_proxy pasture-decepub:80
|
reverse_proxy pasture-decepub:80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mailpit.test {
|
||||||
|
tls internal
|
||||||
|
reverse_proxy pasture-mailpit:8025
|
||||||
|
}
|
||||||
|
|
||||||
hollo.test {
|
hollo.test {
|
||||||
tls internal
|
tls internal
|
||||||
reverse_proxy pasture-hollo:3000
|
reverse_proxy pasture-hollo:3000
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
"AppConfiguration": {
|
"AppConfiguration": {
|
||||||
"SupportedLanguages": [ "en" ],
|
"SupportedLanguages": [ "en" ],
|
||||||
"BackendBaseAddress": "https://privapub.test",
|
"BackendBaseAddress": "https://privapub.test",
|
||||||
"FrontendBaseAddress": "https://privapub.test",
|
"FrontendBaseAddress": "https://decepub.test",
|
||||||
"HashingOptions": { "Iterations": 10101 },
|
"EmailConfiguration": { "SmtpServer": "pasture-mailpit", "SmtpPort": 1025, "UseSSL": false, "SmtpUsername": "privapub@privapub.test", "SmtpPassword": "" },
|
||||||
|
"HashingOptions": { "Iterations": 210000 },
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Key": "pasture-only-key-not-a-secret-pasture-only-key-not-a-secret-0123456789",
|
"Key": "pasture-only-key-not-a-secret-pasture-only-key-not-a-secret-0123456789",
|
||||||
"Issuer": "http://privapub.test",
|
"Issuer": "http://privapub.test",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Mailpit 1.31.4 as mailpit.test: the pasture's mail server. PrivaPub sends to it on pasture-mailpit:1025 (plain SMTP, any
|
||||||
|
# login accepted, appsettings.Pasture.json's EmailConfiguration), and the scenarios read what arrived through its API
|
||||||
|
# (https://mailpit.test:6443/api/v1/...). Nothing leaves the pasture.
|
||||||
|
MAILPIT_IMAGE=${MAILPIT_IMAGE:-docker.io/axllent/mailpit:v1.31.4}
|
||||||
|
|
||||||
|
mailpit_up() {
|
||||||
|
podman run -d --replace --name pasture-mailpit --label pasture=1 --network $net \
|
||||||
|
-e MP_SMTP_AUTH_ACCEPT_ANY=1 -e MP_SMTP_AUTH_ALLOW_INSECURE=1 -e MP_MAX_MESSAGES=5000 \
|
||||||
|
$MAILPIT_IMAGE >/dev/null
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
site mailpit.test -s -o /dev/null -w '%{http_code}' https://mailpit.test:6443/api/v1/info 2>/dev/null | grep -q 200 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "mailpit: https://mailpit.test:6443"
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Accounts (completion program R2): an email address is set only with the password and a code sent to it; recovery writes
|
||||||
|
# only to a verified address, and its link works once; a password change and "sign out everywhere" end every session,
|
||||||
|
# while a logout ends only its own device; a hash made at fewer iterations is made again at sign-in; recovery codes expire
|
||||||
|
# through a TTL index. Needs the mailpit peer.
|
||||||
|
MP=https://mailpit.test:6443
|
||||||
|
mp() { site mailpit.test -s "$@"; }
|
||||||
|
# mp_text <address>: the newest message sent to the address, its text, or nothing
|
||||||
|
mp_text() {
|
||||||
|
local id
|
||||||
|
id=$(mp "$MP/api/v1/search?query=to:$1&limit=1" | j "print(d['messages'][0]['ID'] if d and d.get('messages') else '')")
|
||||||
|
[ -n "$id" ] && mp "$MP/api/v1/message/$id" | j "print(d['Text'])"
|
||||||
|
}
|
||||||
|
mp_count() { mp "$MP/api/v1/search?query=to:$1" | j "print(d['messages_count'] if d else 0)"; }
|
||||||
|
api() { local jwt=$1 method=$2 path=$3; shift 3; curl -s -X "$method" -H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' "$P$path" "$@"; }
|
||||||
|
code_of() { local jwt=$1 method=$2 path=$3; shift 3; curl -s -o /dev/null -w '%{http_code}' -X "$method" -H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' "$P$path" "$@"; }
|
||||||
|
login() { curl -s -X POST "$P/clientapi/user/login" -H 'Content-Type: application/json' -d "{\"userName\":\"$1\",\"password\":\"$2\"}" | j "print(d['token'])"; }
|
||||||
|
|
||||||
|
echo "accounts"
|
||||||
|
run=$(date +%s)
|
||||||
|
user="acct$run"; pw='Accounts-Pass-1!'; pw2='Accounts-Pass-2!'; mail="$user@example.test"
|
||||||
|
curl -s -o /dev/null -X POST "$P/clientapi/user/signup" -H 'Content-Type: application/json' -d "{\"userName\":\"$user\",\"password\":\"$pw\"}"
|
||||||
|
A=$(login "$user" "$pw")
|
||||||
|
[ -n "$A" ] && ok "a login for the scenario" || { ko "no login for the scenario"; return 1; }
|
||||||
|
|
||||||
|
echo " an email address, with the password and a code"
|
||||||
|
[ "$(code_of "$A" POST /clientapi/user/update -d "{\"email\":\"$mail\"}")" = 422 ] \
|
||||||
|
&& ok "the old route changes no address" || ko "the old route still changes the address"
|
||||||
|
[ "$(code_of "$A" POST /clientapi/user/email -d "{\"email\":\"$mail\",\"password\":\"wrong-Pass-9\"}")" = 403 ] \
|
||||||
|
&& ok "a wrong password asks for no address" || ko "a wrong password was taken"
|
||||||
|
sentence=$(api "$A" POST /clientapi/user/email -d "{\"email\":\"$mail\",\"password\":\"$pw\"}")
|
||||||
|
[ "$(api "$A" GET /clientapi/user/account | j "print(d['pendingEmail'])")" = "$mail" ] \
|
||||||
|
&& ok "the address waits for its code" || ko "the address is not waiting for its code"
|
||||||
|
until_true 20 '[ -n "$(mp_text "$mail")" ]' && ok "the code was emailed" || ko "no code arrived"
|
||||||
|
code=$(mp_text "$mail" | grep -oE '\b[0-9]{6}\b' | head -1)
|
||||||
|
[ "$(code_of "$A" POST /clientapi/user/email/verify -d '{"code":"000000"}')" = 400 ] \
|
||||||
|
&& ok "a wrong code is refused" || ko "a wrong code was taken"
|
||||||
|
[ "$(api "$A" POST /clientapi/user/email/verify -d "{\"code\":\"$code\"}" | j "print(d['isEmailValidated'] and d['email'])")" = "$mail" ] \
|
||||||
|
&& ok "the code verifies the address" || ko "the code did not verify the address"
|
||||||
|
other="acct${run}b"
|
||||||
|
curl -s -o /dev/null -X POST "$P/clientapi/user/signup" -H 'Content-Type: application/json' -d "{\"userName\":\"$other\",\"password\":\"$pw\"}"
|
||||||
|
O=$(login "$other" "$pw")
|
||||||
|
[ "$(api "$O" POST /clientapi/user/email -d "{\"email\":\"$mail\",\"password\":\"$pw\"}")" = "$sentence" ] \
|
||||||
|
&& ok "an address in use gets the same answer" || ko "an address in use is told apart"
|
||||||
|
sleep 6
|
||||||
|
[ "$(mp_count "$mail")" = 1 ] && ok "no code goes to an address another login verified" || ko "a code went to an address in use"
|
||||||
|
|
||||||
|
echo " recovery"
|
||||||
|
curl -s -o /dev/null -X POST "$P/clientapi/user/recover/password" -H 'Content-Type: application/json' -d "{\"email\":\"$mail\"}"
|
||||||
|
until_true 20 '[ "$(mp_count "$mail")" = 2 ]' && ok "the recovery link was emailed" || ko "no recovery link arrived"
|
||||||
|
rc=$(mp_text "$mail" | grep -oE 'rc=[0-9a-f]{64}' | head -1 | cut -d= -f2)
|
||||||
|
grep -q 'https://decepub.test/password-recovery?rc=' <<<"$(mp_text "$mail")" && ok "the link opens decePub" || ko "the link does not open decePub"
|
||||||
|
[ "$(curl -s -X POST "$P/clientapi/user/recover/valid" -H 'Content-Type: application/json' -d "\"$rc\"")" = true ] \
|
||||||
|
&& ok "the recovery code is valid" || ko "the recovery code is not valid"
|
||||||
|
B=$(login "$user" "$pw")
|
||||||
|
curl -s -o /dev/null -X POST "$P/clientapi/user/recover/update/password" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"newPassword\":\"$pw2\",\"repeatedPassword\":\"$pw2\",\"recoveryCode\":\"$rc\"}"
|
||||||
|
[ "$(curl -s -X POST "$P/clientapi/user/recover/valid" -H 'Content-Type: application/json' -d "\"$rc\"")" = false ] \
|
||||||
|
&& ok "the recovery code works once" || ko "the recovery code works twice"
|
||||||
|
[ "$(code_of "$B" GET /clientapi/user/account)" = 401 ] && ok "recovery ended every session" || ko "a session outlived the recovery"
|
||||||
|
[ -n "$(login "$user" "$pw2")" ] && ok "the new password signs in" || ko "the new password does not sign in"
|
||||||
|
unverified="acct${run}c@example.test"
|
||||||
|
api "$O" POST /clientapi/user/email -d "{\"email\":\"$unverified\",\"password\":\"$pw\"}" >/dev/null
|
||||||
|
until_true 20 '[ "$(mp_count "$unverified")" = 1 ]'
|
||||||
|
curl -s -o /dev/null -X POST "$P/clientapi/user/recover/password" -H 'Content-Type: application/json' -d "{\"email\":\"$unverified\"}"
|
||||||
|
curl -s -o /dev/null -X POST "$P/clientapi/user/recover/password" -H 'Content-Type: application/json' -d "{\"userName\":\"$other\"}"
|
||||||
|
sleep 6
|
||||||
|
[ "$(mp_count "$unverified")" = 1 ] && ok "no recovery goes to an unverified address" || ko "a recovery went to an unverified address"
|
||||||
|
|
||||||
|
echo " sessions"
|
||||||
|
C=$(login "$user" "$pw2"); D=$(login "$user" "$pw2")
|
||||||
|
[ "$(code_of "$C" GET /clientapi/user/logout)" = 200 ] && ok "a device signs out" || ko "signing out failed"
|
||||||
|
[ "$(code_of "$C" GET /clientapi/user/account)" = 401 ] && ok "its token is refused" || ko "its token still works"
|
||||||
|
[ "$(code_of "$D" GET /clientapi/user/account)" = 200 ] && ok "another device stays signed in" || ko "another device was signed out"
|
||||||
|
renewed=$(api "$D" GET /clientapi/user/sniff/again | j "print(d['token'])")
|
||||||
|
api "$D" GET /clientapi/user/logout >/dev/null
|
||||||
|
[ "$(code_of "$renewed" GET /clientapi/user/account)" = 401 ] && ok "a renewed token belongs to the session it renewed" || ko "a renewed token outlived its session's logout"
|
||||||
|
E=$(login "$user" "$pw2"); F=$(login "$user" "$pw2")
|
||||||
|
changed=$(api "$E" POST /clientapi/user/update/password \
|
||||||
|
-d "{\"oldPassword\":\"$pw2\",\"newPassword\":\"$pw\",\"repeatedPassword\":\"$pw\"}" | j "print(d['token'])")
|
||||||
|
[ "$(code_of "$F" GET /clientapi/user/account)" = 401 ] && ok "a password change ends the other sessions" || ko "another session outlived a password change"
|
||||||
|
[ "$(code_of "$changed" GET /clientapi/user/account)" = 200 ] && ok "the device that changed it goes on" || ko "the device that changed it was signed out"
|
||||||
|
G=$(login "$user" "$pw")
|
||||||
|
[ "$(code_of "$G" POST /clientapi/user/logout/everywhere)" = 200 ] && ok "signing out everywhere" || ko "signing out everywhere failed"
|
||||||
|
[ "$(code_of "$changed" GET /clientapi/user/account)" = 401 ] && [ "$(code_of "$G" GET /clientapi/user/account)" = 401 ] \
|
||||||
|
&& ok "every session ended" || ko "a session outlived signing out everywhere"
|
||||||
|
|
||||||
|
echo " hashes"
|
||||||
|
old_hash=$(python3 -c "
|
||||||
|
import base64,hashlib,os
|
||||||
|
salt=os.urandom(16)
|
||||||
|
key=hashlib.pbkdf2_hmac('sha512', b'$pw', salt, 10101, 32)
|
||||||
|
print(base64.b64encode(key).decode()+'~'+base64.b64encode(salt).decode()+'~10101')")
|
||||||
|
podman exec pasture-mongo mongosh --quiet PrivaPub --eval "db.RootUser.updateOne({UserName: '$user'}, {\$set: {HashedPassword: '$old_hash'}})" >/dev/null
|
||||||
|
[ -n "$(login "$user" "$pw")" ] && ok "an old hash still signs in" || ko "an old hash no longer signs in"
|
||||||
|
iterations=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.RootUser.findOne({UserName: '$user'}).HashedPassword.split('~')[2])")
|
||||||
|
[ "$iterations" != 10101 ] && ok "it was made again at $iterations iterations" || ko "the old hash was kept"
|
||||||
|
ttl=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.EmailRecovery.getIndexes().some(i => i.expireAfterSeconds === 0) && db.EmailVerification.getIndexes().some(i => i.expireAfterSeconds === 0))")
|
||||||
|
[ "$ttl" = true ] && ok "recovery and verification codes expire by TTL" || ko "no TTL on the codes"
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
# PrivaPub runs with SecureMode on, as in production, unless PRIVAPUB_ENV says otherwise; DOTNET and PASTURE_PORT reach
|
# PrivaPub runs with SecureMode on, as in production, unless PRIVAPUB_ENV says otherwise; DOTNET and PASTURE_PORT reach
|
||||||
# run.sh as they are. Each batch's output is kept in out/sweep-<batch>.log.
|
# run.sh as they are. Each batch's output is kept in out/sweep-<batch>.log.
|
||||||
# usage: tools/pasture/sweep.sh [A|B|C|D ...] (default: every batch, in that order)
|
# usage: tools/pasture/sweep.sh [A|B|C|D ...] (default: every batch, in that order)
|
||||||
# A gts mastodon misskey sharkey akkoma, with relay and aoderelay; scenarios: those five, threads followsync pins
|
# A gts mastodon misskey sharkey akkoma, with relay, aoderelay and mailpit; scenarios: those five, threads followsync
|
||||||
# moves relay (relay's covers both relays)
|
# pins moves relay (relay's covers both relays) accounts (mailpit's)
|
||||||
# B lemmy lemmy19 piefed mbin nodebb
|
# B lemmy lemmy19 piefed mbin nodebb
|
||||||
# C peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale
|
# C peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale
|
||||||
# D friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla
|
# D friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla
|
||||||
@@ -19,7 +19,7 @@ export PRIVAPUB_ENV=${PRIVAPUB_ENV:-Federation__SecureMode=true}
|
|||||||
|
|
||||||
batch_peers() {
|
batch_peers() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
A) echo "gts mastodon misskey sharkey akkoma relay aoderelay" ;;
|
A) echo "gts mastodon misskey sharkey akkoma relay aoderelay mailpit" ;;
|
||||||
B) echo "lemmy lemmy19 piefed mbin nodebb" ;;
|
B) echo "lemmy lemmy19 piefed mbin nodebb" ;;
|
||||||
C) echo "peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale" ;;
|
C) echo "peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale" ;;
|
||||||
D) echo "friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla" ;;
|
D) echo "friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla" ;;
|
||||||
@@ -27,7 +27,7 @@ batch_peers() {
|
|||||||
}
|
}
|
||||||
batch_scenarios() {
|
batch_scenarios() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay" ;;
|
A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay accounts" ;;
|
||||||
*) batch_peers "$1" ;;
|
*) batch_peers "$1" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user