R2 accounts are safe: email by password and code, recovery only to it, sessions that end
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:
thepraandClaude Opus 5.5 committed 2026-10-17 03:13:16 +02:00
1 parent ff5e635dda
commit cff75e3a47
27 files changed
+636 -117

No files matched your search

+20 -1
View File
@@ -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",
+5
View File
@@ -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);
@@ -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>()
+2 -1
View File
@@ -39,7 +39,8 @@ namespace PrivaPub.Models.Jobs
ProcessMedia, ProcessMedia,
ExportArchive, ExportArchive,
ImportArchive, ImportArchive,
SyncFeeds SyncFeeds,
SendEmailVerification
} }
public enum JobState public enum JobState
+18
View File
@@ -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; }
}
}
+9
View File
@@ -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;
+7 -24
View File
@@ -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;
}
} }
} }
+73
View File
@@ -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");
}
}
}
}
+8 -2
View File
@@ -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);
} }
} }
+41
View File
@@ -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);
}
}
}
+9 -26
View File
@@ -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)
+3
View File
@@ -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;
} }
+21 -3
View File
@@ -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);
}
} }
} }
+145 -35
View File
@@ -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);
}
}
} }
} }
+11 -4
View File
@@ -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()));
@@ -57,4 +64,4 @@ namespace PrivaPub.StaticServices
return jwtUser; return jwtUser;
} }
} }
} }
+1 -1
View File
@@ -42,7 +42,7 @@
"SmtpPassword": "c4kXUJFQeKC2dVQbZqxZ" "SmtpPassword": "c4kXUJFQeKC2dVQbZqxZ"
}, },
"HashingOptions": { "HashingOptions": {
"Iterations": 10101 "Iterations": 210000
}, },
"Jwt": { "Jwt": {
"Key": "bbLOwQyh0jYtJ8Dn6WKCneydLJkqeElqUM03Qwtr7pEMyq/qCZD6v/Sh5Kwrq6YpvJm8GSTHfBiCajUmicfgKw==", "Key": "bbLOwQyh0jYtJ8Dn6WKCneydLJkqeElqUM03Qwtr7pEMyq/qCZD6v/Sh5Kwrq6YpvJm8GSTHfBiCajUmicfgKw==",
+1 -1
View File
@@ -46,7 +46,7 @@
"SmtpPassword": "c4kXUJFQeKC2dVQbZqxZ" "SmtpPassword": "c4kXUJFQeKC2dVQbZqxZ"
}, },
"HashingOptions": { "HashingOptions": {
"Iterations": 10101 "Iterations": 210000
}, },
"Jwt": { "Jwt": {
"Key": "FVkEHw8t84HVXE/+3fyY6gH1AJIlLVNE9UqOzFNBdRKsdFuKrCFBvys0IQfZu+d2Qgg6KtslgntiedPMO98iKg==", "Key": "FVkEHw8t84HVXE/+3fyY6gH1AJIlLVNE9UqOzFNBdRKsdFuKrCFBvys0IQfZu+d2Qgg6KtslgntiedPMO98iKg==",
+5
View File
@@ -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
+3 -2
View File
@@ -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",
+15
View File
@@ -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"
}
+98
View File
@@ -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"
+4 -4
View File
@@ -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
} }