Everything on, phase 3: sign-in and recovery tell nothing, recovered passwords end sessions, deleted roots are gone everywhere
Owner decision 2026-10-04: fix the account privacy findings.
- Sign-in. Every failure answers "That username and password do not match." after the same work: an unknown login
is hashed against a decoy, and the comparison is constant-time. "Banned" is told only to someone who gave the right
password. This covers /clientapi/user/login, /invitation/login and /oauth/login.
- Recovery.
- Every request answers the same sentence and queues a SendRecovery job, whether or not the account exists or has an
email. The lookup, the code and SMTP move to RecoveryJob, so neither the answer nor its timing says anything.
- Codes are kept only as a SHA-256 hash, for one hour. Migration _011 drops the plaintext ones, which never expired.
- A recovered password ends every session of the root. RootSessions sets CredentialsChangedAt, which JwtEvents
checks against the JWT's issue time, now stamped as nbf, and revokes each persona's OAuth tokens and authorizations.
- Deleting a root (RootRemoval: the admin route, or the restored self-delete at /clientapi/user/delete, which asks for
the password).
- Its sessions end.
- Each persona and each group it owns sends Delete{Actor} to its followers, its members and the accounts it follows.
- The personas' posts are emptied.
- /peasants/{name} answers 410 with a Tombstone (formerType Person or Group), as do its inbox and WebFinger, through
LocalActorService.Gone. The names stay reserved.
- The root keeps only a unique `deleted-{id}` name; the second deletion on an instance used to collide on
"Deleted user".
Also, from phase 2's pasture: GoToSocial files a circle post like a DM and shows it only to accounts it mentions. Each
member's copy, and a member's refetch, now also mentions that member silently. The GoToSocial scenario checks circle
posts in conversations, like DMs, and they pass there now, as on Mastodon.
657 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
1 parent
fcd35f5043
commit
8c2eba6cbb
25 files changed
+665
-248
No files matched your search
@@ -252,6 +252,16 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
|
||||
|
||||
- **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a
|
||||
client** ("Something went wrong." instead).
|
||||
- **Sign-in and recovery never tell which logins or addresses exist** (owner decision 2026-10-04): every failed sign-in
|
||||
gets "That username and password do not match." after the same hashing (`RootUsersService.Decoy`, a constant-time
|
||||
comparison), and only a correct password learns of a ban. Every recovery request answers the same sentence and
|
||||
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`: `CredentialsChangedAt` for `/clientapi`
|
||||
JWTs, OpenIddict revocation for each persona).
|
||||
- **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
|
||||
and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer
|
||||
410 (`LocalActorService.Gone`) while the names stay reserved. The root keeps only a `deleted-{id}` name.
|
||||
- **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one
|
||||
equal to the login.
|
||||
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
// Deleting one's own login, and with it every persona and group it owns, asks for the password again.
|
||||
public class RemoveSelfForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
public string BaseAddress => Base;
|
||||
|
||||
public Task<GoneActor> Gone(string userName, CancellationToken token) => Task.FromResult<GoneActor>(default);
|
||||
public Task<LocalActor> FindByUserName(string userName, CancellationToken token) =>
|
||||
Task.FromResult(userName.Equals("alice", StringComparison.OrdinalIgnoreCase)
|
||||
? new LocalActor { Id = "a1", UserName = "alice", BaseAddress = Base, Kind = LocalActorKind.Person }
|
||||
|
||||
@@ -142,6 +142,7 @@ namespace PrivaPub.Tests.Federation
|
||||
// the member's copy names the member, so Mastodon and GoToSocial keep it, and names nobody else
|
||||
Assert.Equal(new[] { member.Id }, create["cc"]!.AsArray().Select(t => t!.GetValue<string>()));
|
||||
Assert.Equal(new[] { member.Id }, create["object"]!["cc"]!.AsArray().Select(t => t!.GetValue<string>()));
|
||||
Assert.Contains(create["object"]!["tag"]!.AsArray(), t => t!["type"]!.GetValue<string>() == "Mention" && t["href"]!.GetValue<string>() == member.Id);
|
||||
Assert.DoesNotContain(Addressing.Public, create.ToJsonString());
|
||||
Assert.Empty(await _harness.Outgoing(follower.SharedInbox));
|
||||
Assert.Empty((await _harness.Outgoing(member.SharedInbox)).Where(a => a["type"]!.GetValue<string>() == "Announce"));
|
||||
|
||||
@@ -6,6 +6,8 @@ using MongoDB.Entities;
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
@@ -136,9 +138,11 @@ namespace PrivaPub.Tests.Http
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
Assert.Equal(root.Id, (await ok.JsonBody())["userId"]!.GetValue<string>());
|
||||
// a wrong password and an unknown login get the same answer, so sign-in tells nobody which logins exist
|
||||
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
|
||||
Assert.Equal("Wrong password.", await Message(wrong));
|
||||
Assert.Equal(RootUsersService.NoMatch, await Message(wrong));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode);
|
||||
Assert.Equal(await Message(wrong), await Message(unknown));
|
||||
Assert.Equal(HttpStatusCode.OK, logout.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymousLogout.StatusCode);
|
||||
}
|
||||
@@ -290,42 +294,59 @@ namespace PrivaPub.Tests.Http
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recovery_without_an_email_is_refused()
|
||||
public async Task Recovery_answers_the_same_whoever_asks_and_queues_the_work()
|
||||
{
|
||||
var root = await _host.SignUp("noemail");
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||
var withEmail = await _host.SignUp("withemail");
|
||||
var withoutEmail = await _host.SignUp("noemail");
|
||||
var email = $"{Guid.NewGuid():N}@example.test";
|
||||
using (var signedIn = _host.As(withEmail.Jwt))
|
||||
Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode);
|
||||
using var client = _host.Client();
|
||||
|
||||
var response = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
|
||||
var unknown = await client.PostJson("/clientapi/user/recover/password", new { userName = Name("nobody") });
|
||||
var neither = await client.PostJson("/clientapi/user/recover/password", new { });
|
||||
var answers = new[]
|
||||
{
|
||||
await client.PostJson("/clientapi/user/recover/password", new { userName = withEmail.UserName }),
|
||||
await client.PostJson("/clientapi/user/recover/password", new { email }),
|
||||
await client.PostJson("/clientapi/user/recover/password", new { userName = withoutEmail.UserName }),
|
||||
await client.PostJson("/clientapi/user/recover/password", new { userName = Name("nobody") }),
|
||||
await client.PostJson("/clientapi/user/recover/password", new { email = $"{Guid.NewGuid():N}@example.test" })
|
||||
};
|
||||
var bodies = new List<string>();
|
||||
foreach (var answer in answers)
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
|
||||
bodies.Add(await answer.Content.ReadAsStringAsync(token));
|
||||
}
|
||||
|
||||
Assert.Equal(HttpStatusCode.Locked, response.StatusCode);
|
||||
Assert.Contains("doesn't have an email", await Message(response));
|
||||
Assert.False(await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, neither.StatusCode);
|
||||
Assert.Single(bodies.Distinct());
|
||||
Assert.Contains("recovery link is on its way", bodies[0]);
|
||||
var queued = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.SendRecovery && j.CreatedAt >= since).ExecuteAsync(token);
|
||||
Assert.True(queued.Count >= answers.Length);
|
||||
Assert.Contains(queued, j => j.Payload.Contains(withEmail.Id));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostJson("/clientapi/user/recover/password", new { })).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recovery_through_an_unreachable_mail_server_says_so_and_nothing_more()
|
||||
public async Task The_recovery_job_keeps_only_a_hash_and_an_unreachable_mail_server_says_nothing_to_anyone()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var root = await _host.SignUp("smtp");
|
||||
var email = $"{Guid.NewGuid():N}@example.test";
|
||||
using (var signedIn = _host.As(root.Jwt))
|
||||
Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode);
|
||||
using var client = _host.Client();
|
||||
|
||||
var byName = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
|
||||
var byEmail = await client.PostJson("/clientapi/user/recover/password", new { email });
|
||||
var answer = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
|
||||
await _host.Run(j => j.Kind == JobKind.SendRecovery && j.Payload.Contains(root.Id), token);
|
||||
|
||||
foreach (var response in new[] { byName, byEmail })
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Equal("Failed to send email.", JsonNode.Parse(body)!["errorMessage"]!.GetValue<string>());
|
||||
foreach (var leak in new[] { "xception", "efused", "ocket", "127.0.0.1", "smtp", "SMTP", " at " })
|
||||
Assert.DoesNotContain(leak, body);
|
||||
}
|
||||
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
|
||||
var recovery = await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteFirstAsync(token);
|
||||
Assert.NotNull(recovery);
|
||||
Assert.Null(recovery.RecoveryCode);
|
||||
Assert.Equal(64, recovery.CodeHash.Length);
|
||||
Assert.InRange(recovery.ExpiresAt, DateTime.UtcNow.AddMinutes(50), DateTime.UtcNow.AddMinutes(61));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -348,7 +369,10 @@ namespace PrivaPub.Tests.Http
|
||||
{
|
||||
var root = await _host.SignUp("recover");
|
||||
var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
|
||||
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, RecoveryCode = code }, TestContext.Current.CancellationToken);
|
||||
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddHours(1) },
|
||||
TestContext.Current.CancellationToken);
|
||||
var persona = await _host.Persona(root, "recovered");
|
||||
var apiToken = await _host.MastodonToken(persona);
|
||||
using var client = _host.Client();
|
||||
|
||||
var valid = await client.PostAsync("/clientapi/user/recover/valid", new StringContent($"\"{code}\"", Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken);
|
||||
@@ -361,6 +385,29 @@ namespace PrivaPub.Tests.Http
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await LogIn(root.UserName, root.Password)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, NewPassword)).StatusCode);
|
||||
Assert.False(await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
|
||||
// whoever held the old sessions may be why the password was recovered: they end
|
||||
using (var oldSession = _host.As(root.Jwt))
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await oldSession.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken)).StatusCode);
|
||||
using (var oldApp = _host.Client())
|
||||
{
|
||||
oldApp.DefaultRequestHeaders.Authorization = new("Bearer", apiToken);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await oldApp.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_expired_recovery_code_changes_nothing()
|
||||
{
|
||||
var root = await _host.SignUp("expired");
|
||||
var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
|
||||
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddMinutes(-1) },
|
||||
TestContext.Current.CancellationToken);
|
||||
using var client = _host.Client();
|
||||
|
||||
var change = await client.PostJson("/clientapi/user/recover/update/password", new { newPassword = NewPassword, repeatedPassword = NewPassword, recoveryCode = code });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, change.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, root.Password)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Tests.Http
|
||||
{
|
||||
// Deleting a root deletes its personas and their groups everywhere (owner decision 2026-10-04).
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class RootRemovalTests : IAsyncLifetime
|
||||
{
|
||||
PrivaPubHost _host;
|
||||
HttpClient _client;
|
||||
Peer _peer;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_host = await PrivaPubHost.Shared();
|
||||
_client = _host.Client();
|
||||
_peer = await Peer.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_client?.Dispose();
|
||||
if (_peer != default)
|
||||
await _peer.DisposeAsync();
|
||||
}
|
||||
|
||||
async Task<HttpResponseMessage> AdminRemoves(params string[] rootIds)
|
||||
{
|
||||
var admin = await _host.Admin();
|
||||
using var client = _host.As(admin.Jwt);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, "/clientapi/admin/remove/users") { Content = JsonContent.Create(new { userIdList = rootIds }) };
|
||||
return await client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_removed_roots_personas_and_groups_are_deleted_everywhere_and_say_so()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var root = await _host.SignUp("leaving");
|
||||
var persona = await _host.Persona(root, "leaving");
|
||||
var follower = new RemoteActor(_peer, "fan");
|
||||
var member = new RemoteActor(_peer, "member");
|
||||
await DB.Default.SaveAsync(new Follower { LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = follower.Id, InboxURL = follower.Id + "/inbox" }, token);
|
||||
var group = await _host.Group(persona, community: true);
|
||||
var groupId = group["id"]!.GetValue<string>();
|
||||
await DB.Default.SaveAsync(new Follower { LocalActorId = groupId, LocalActorKind = LocalActorKind.Group, ActorURI = member.Id, InboxURL = member.Id + "/inbox" }, token);
|
||||
var post = await _host.Publish(persona, "goodbye soon");
|
||||
var apiToken = await _host.MastodonToken(persona);
|
||||
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(root.Id)).StatusCode);
|
||||
|
||||
var toFollower = Assert.Single(await Jobs.Deliveries(follower.Id + "/inbox", since, token), d => d["type"]!.GetValue<string>() == "Delete");
|
||||
Assert.Equal(persona.ActorUri(), toFollower["object"]!.GetValue<string>());
|
||||
Assert.Equal(persona.ActorUri(), toFollower["actor"]!.GetValue<string>());
|
||||
var groupName = group["userName"]!.GetValue<string>();
|
||||
Assert.Contains(await Jobs.Deliveries(member.Id + "/inbox", since, token),
|
||||
d => d["type"]!.GetValue<string>() == "Delete" && d["object"]!.GetValue<string>() == $"{PrivaPubHost.Base}/peasants/{groupName}");
|
||||
var gone = await _client.Fetch($"/peasants/{persona.UserName}");
|
||||
Assert.Equal(HttpStatusCode.Gone, gone.Status);
|
||||
Assert.Equal("Person", gone.Json["formerType"]!.GetValue<string>());
|
||||
Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch($"/peasants/{groupName}")).Status);
|
||||
Assert.Equal(HttpStatusCode.Gone, (await _client.GetAsync($"/.well-known/webfinger?resource=acct:{persona.UserName}@{PrivaPubHost.Host}", token)).StatusCode);
|
||||
var stored = await DB.Default.Find<PostEntity>().MatchID(post.ID).ExecuteFirstAsync(token);
|
||||
Assert.NotNull(stored.DeletedAt);
|
||||
Assert.Null(stored.ContentHtml);
|
||||
Assert.NotNull((await DB.Default.Find<GroupEntity>().MatchID(groupId).ExecuteFirstAsync(token)).DeletionAt);
|
||||
var removed = await DB.Default.Find<RootUser>().MatchID(root.Id).ExecuteFirstAsync(token);
|
||||
Assert.Equal($"deleted-{root.Id}", removed.UserName);
|
||||
Assert.Null(removed.HashedPassword);
|
||||
using var app = _host.Client();
|
||||
app.DefaultRequestHeaders.Authorization = new("Bearer", apiToken);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await app.GetAsync("/api/v1/accounts/verify_credentials", token)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_second_removal_does_not_collide_with_the_first()
|
||||
{
|
||||
var first = await _host.SignUp("first");
|
||||
var second = await _host.SignUp("second");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(first.Id)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(second.Id)).StatusCode);
|
||||
|
||||
Assert.NotNull((await DB.Default.Find<RootUser>().MatchID(second.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).DeletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_root_deletes_itself_only_with_its_password()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var root = await _host.SignUp("self");
|
||||
var persona = await _host.Persona(root, "self");
|
||||
using var client = _host.As(root.Jwt);
|
||||
|
||||
var refused = await client.PostJson("/clientapi/user/delete", new { password = "Not-The-Password-1" });
|
||||
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
|
||||
Assert.Null((await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt);
|
||||
|
||||
var accepted = await client.PostJson("/clientapi/user/delete", new { password = root.Password });
|
||||
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
|
||||
Assert.NotNull((await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/clientapi/user/sniff/again", token)).StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -361,7 +361,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
return Ok(result);
|
||||
return Ok(result.Data);//the same sentence for everyone
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -414,26 +414,23 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
}
|
||||
}
|
||||
|
||||
//[HttpDelete, Route("delete"), Authorize(Policy = Policies.IsUser)]
|
||||
//public async Task<IActionResult> RemoveSelf()
|
||||
//{
|
||||
// var result = new WebResult();
|
||||
// try
|
||||
// {
|
||||
// //result = await UsersService.RemoveUserAsync(User.GetUserId());
|
||||
// if (!result.IsValid)
|
||||
// return StatusCode(result.StatusCode, result);
|
||||
|
||||
// await HttpContext.SignOutAsync();
|
||||
|
||||
// return Ok();
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveSelf)}()");
|
||||
// return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
// }
|
||||
//}
|
||||
[HttpPost, Route("/clientapi/user/delete"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> RemoveSelf(RemoveSelfForm form)
|
||||
{
|
||||
var result = new WebResult();
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||
try
|
||||
{
|
||||
result = await UsersService.RemoveSelfAsync(User.GetUserId(), form.Password);
|
||||
return result.IsValid ? Ok() : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveSelf)}()");
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion User endpoints
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ namespace PrivaPub.Federation.Actors
|
||||
{
|
||||
string BaseAddress { get; }
|
||||
Task<LocalActor> FindByUserName(string userName, CancellationToken token);
|
||||
Task<GoneActor> Gone(string userName, CancellationToken token);
|
||||
Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token);
|
||||
Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
|
||||
Task<LocalActor> GetInstanceActor(CancellationToken token);
|
||||
@@ -71,6 +72,9 @@ namespace PrivaPub.Federation.Actors
|
||||
LocalActor FromGroup(GroupEntity group);
|
||||
}
|
||||
|
||||
// a persona or group that was deleted: its name stays reserved, and its documents answer 410 with this
|
||||
public sealed record GoneActor(string Uri, string FormerType, DateTime DeletedAt);
|
||||
|
||||
public class LocalActorService : ILocalActorService
|
||||
{
|
||||
public const string InstanceUserName = "privapub";
|
||||
@@ -113,6 +117,18 @@ namespace PrivaPub.Federation.Actors
|
||||
return group == default ? default : FromGroup(group);
|
||||
}
|
||||
|
||||
public async Task<GoneActor> Gone(string userName, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userName))
|
||||
return default;
|
||||
userName = userName.ToLowerInvariant();
|
||||
var avatar = await _dbEntities.Avatars.Match(a => a.UserName == userName && a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (avatar != default)
|
||||
return new GoneActor($"{BaseAddress}/peasants/{avatar.UserName}", "Person", avatar.DeletionAt.Value);
|
||||
var group = await _dbEntities.Groups.Match(g => g.UserName == userName && g.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
return group == default ? default : new GoneActor($"{BaseAddress}/peasants/{group.UserName}", "Group", group.DeletionAt.Value);
|
||||
}
|
||||
|
||||
public async Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token)
|
||||
{
|
||||
switch (kind)
|
||||
|
||||
@@ -53,6 +53,20 @@ namespace PrivaPub.Federation.Controllers
|
||||
public async Task<IActionResult> GetActor(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local == default && await _localActors.Gone(actor, token) is { } gone)
|
||||
return new ContentResult
|
||||
{
|
||||
Content = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = gone.Uri,
|
||||
["type"] = "Tombstone",
|
||||
["formerType"] = gone.FormerType,
|
||||
["deleted"] = ActivityPubRenderer.Timestamp(gone.DeletedAt)
|
||||
}.ToJsonString(),
|
||||
ContentType = ActivityContentType,
|
||||
StatusCode = StatusCodes.Status410Gone
|
||||
};
|
||||
if (local is not { IsFederated: true })
|
||||
return NotFound();
|
||||
if (WantsHtml() && local.Kind != LocalActorKind.Application)
|
||||
@@ -282,6 +296,8 @@ namespace PrivaPub.Federation.Controllers
|
||||
public async Task<IActionResult> Inbox(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local == default && await _localActors.Gone(actor, token) != default)
|
||||
return StatusCode(StatusCodes.Status410Gone);
|
||||
if (local is not { IsFederated: true })
|
||||
return Answer(_inbox.NoSuchRecipient(Request));
|
||||
return Answer(await _inbox.Receive(Request, local, token));
|
||||
@@ -328,7 +344,9 @@ namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
var circle = await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token);
|
||||
var note = ActivityPubRenderer.Note(post, local, _localActors.FromGroup(circle), post.InReplyToURI);
|
||||
return (local, post, OutboxPublisher.Naming(note, SignedFetchAuthorizer.CircleReaders(circle, requester)));
|
||||
var readers = SignedFetchAuthorizer.CircleReaders(circle, requester);
|
||||
var named = readers.Count == 0 ? new List<Models.User.ForeignAvatar>() : await _dbEntities.ForeignAvatars.Match(a => readers.Contains(a.ActorURI)).ExecuteAsync(token);
|
||||
return (local, post, OutboxPublisher.Naming(note, named));
|
||||
}
|
||||
var rendered = post.Visibility == PostVisibility.Direct
|
||||
? ActivityPubRenderer.DirectNote(post, local, Array.Empty<(string, string)>(), post.ContextURI)
|
||||
|
||||
@@ -49,6 +49,8 @@ namespace PrivaPub.Federation.Controllers
|
||||
if (parts.Length != 2 || !parts[1].Equals(domain, StringComparison.OrdinalIgnoreCase))
|
||||
return NotFound();
|
||||
actor = await _localActors.FindByUserName(parts[0], token);
|
||||
if (actor == default && await _localActors.Gone(parts[0], token) != default)
|
||||
return StatusCode(StatusCodes.Status410Gone);
|
||||
}
|
||||
else
|
||||
actor = await _localActors.FindByUri(resource, token);
|
||||
|
||||
@@ -2,6 +2,7 @@ using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -75,19 +76,18 @@ namespace PrivaPub.Federation.Outbox
|
||||
}
|
||||
|
||||
async Task<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token) =>
|
||||
(await CircleRecipients(groupId, token)).Select(r => r.Inbox).Distinct(StringComparer.Ordinal).ToList();
|
||||
(await CircleRecipients(groupId, token)).Select(r => r.InboxURL).Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
async Task<IReadOnlyList<(string Member, string Inbox)>> CircleRecipients(string groupId, CancellationToken token)
|
||||
async Task<IReadOnlyList<ForeignAvatar>> CircleRecipients(string groupId, CancellationToken token)
|
||||
{
|
||||
var circle = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
|
||||
if (circle == default)
|
||||
return Array.Empty<(string, string)>();
|
||||
return Array.Empty<ForeignAvatar>();
|
||||
var remote = circle.Members.Where(m => m.IsForeign).Select(m => m.AvatarId).ToList();
|
||||
if (remote.Count == 0)
|
||||
return Array.Empty<(string, string)>();
|
||||
return Array.Empty<ForeignAvatar>();
|
||||
return (await _dbEntities.ForeignAvatars.Match(a => remote.Contains(a.ActorURI)).ExecuteAsync(token))
|
||||
.Where(a => !string.IsNullOrEmpty(a.InboxURL))
|
||||
.Select(a => (a.ActorURI, a.InboxURL))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -95,8 +95,8 @@ namespace PrivaPub.Federation.Outbox
|
||||
{
|
||||
if (post.Visibility == PostVisibility.Circle)
|
||||
{
|
||||
foreach (var (member, inbox) in await CircleRecipients(post.GroupId, token))
|
||||
await _delivery.Enqueue(author, new[] { inbox }, Naming(activity, new[] { member }), token);
|
||||
foreach (var member in await CircleRecipients(post.GroupId, token))
|
||||
await _delivery.Enqueue(author, new[] { member.InboxURL }, Naming(activity, new[] { member }), token);
|
||||
return;
|
||||
}
|
||||
var inboxes = await Audience(author, post, token);
|
||||
@@ -110,27 +110,44 @@ namespace PrivaPub.Federation.Outbox
|
||||
await Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, group, reason), token);
|
||||
}
|
||||
|
||||
// Mastodon and GoToSocial keep a post only when it names one of their own accounts, and a circle post names only the
|
||||
// circle. So each member's copy, or a member's refetch, also names that member in cc (owner decision 2026-10-04):
|
||||
// it tells each member nothing but that they are in the circle.
|
||||
public static JsonObject Naming(JsonObject activityOrObject, IEnumerable<string> members)
|
||||
// Mastodon keeps a post only when it names one of its own accounts, and GoToSocial shows a post that is neither
|
||||
// public nor for followers only to the accounts it mentions; a circle post names only the circle. So each member's
|
||||
// copy, or a member's refetch, also names that member in cc and mentions them, silently, in its tags (owner decision
|
||||
// 2026-10-04): it tells each member nothing but that they are in the circle.
|
||||
public static JsonObject Naming(JsonObject activityOrObject, IEnumerable<ForeignAvatar> members)
|
||||
{
|
||||
var named = members.ToList();
|
||||
var copy = (JsonObject)activityOrObject.DeepClone();
|
||||
Name(copy, members);
|
||||
if (copy["object"] is JsonObject inner && inner.ContainsKey("to"))
|
||||
Name(inner, members);
|
||||
if (copy["object"] is JsonObject inner)
|
||||
{
|
||||
Name(copy, named, mention: false);
|
||||
if (inner.ContainsKey("to"))
|
||||
Name(inner, named, mention: true);
|
||||
}
|
||||
else
|
||||
Name(copy, named, mention: copy.ContainsKey("attributedTo"));
|
||||
return copy;
|
||||
}
|
||||
|
||||
static void Name(JsonObject node, IEnumerable<string> members)
|
||||
static void Name(JsonObject node, IReadOnlyList<ForeignAvatar> members, bool mention)
|
||||
{
|
||||
var cc = node["cc"] as JsonArray ?? new JsonArray();
|
||||
var tags = node["tag"] as JsonArray ?? new JsonArray();
|
||||
foreach (var member in members)
|
||||
if (!cc.Any(c => c?.GetValue<string>() == member))
|
||||
cc.Add(member);
|
||||
{
|
||||
if (!cc.Any(c => c?.GetValue<string>() == member.ActorURI))
|
||||
cc.Add(member.ActorURI);
|
||||
if (mention && !tags.Any(t => t?["href"]?.GetValue<string>() == member.ActorURI))
|
||||
tags.Add(new JsonObject { ["type"] = "Mention", ["href"] = member.ActorURI, ["name"] = Handle(member) });
|
||||
}
|
||||
node["cc"] = cc;
|
||||
if (mention)
|
||||
node["tag"] = tags;
|
||||
}
|
||||
|
||||
static string Handle(ForeignAvatar member) =>
|
||||
string.IsNullOrEmpty(member.UserName) ? member.ActorURI : $"@{member.UserName}@{new Uri(member.ActorURI).Authority}";
|
||||
|
||||
public async Task PublishProfile(LocalActor actor, CancellationToken token)
|
||||
{
|
||||
var document = ActivityPubRenderer.Actor(actor);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Data.Migrations
|
||||
{
|
||||
// Recovery codes were stored in plain text and never expired; from now on only a hash with a one-hour expiry is kept.
|
||||
// The old codes are dropped: anyone mid-recovery asks again.
|
||||
public class _011_recovery_codes_are_hashed : IMigration
|
||||
{
|
||||
public async Task UpgradeAsync() =>
|
||||
await DB.Default.DeleteAsync<EmailRecovery>(r => r.CodeHash == null);
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IPollService, PollService>()
|
||||
.AddSingleton<IQuoteService, QuoteService>()
|
||||
.AddSingleton<IJobHandler, PollRefreshJob>()
|
||||
.AddSingleton<IJobHandler, RecoveryJob>()
|
||||
.AddSingleton<IJobHandler, PollCloseJob>()
|
||||
.AddSingleton<InstanceDescriber>()
|
||||
.AddSingleton<IJobHandler>(services => services.GetRequiredService<InstanceDescriber>())
|
||||
@@ -182,6 +183,8 @@ namespace PrivaPub.Middleware
|
||||
return service
|
||||
.AddTransient<IDataService, DataService>()
|
||||
.AddTransient<IRootUsersService, RootUsersService>()
|
||||
.AddScoped<IRootSessions, RootSessions>()
|
||||
.AddScoped<IRootRemoval, RootRemoval>()
|
||||
.AddTransient<IPublicAvatarUsersService, PublicAvatarUsersService>()
|
||||
.AddTransient<IPrivateAvatarUsersService, PrivateAvatarUsersService>()
|
||||
.AddTransient<IGroupUsersService, GroupUsersService>()
|
||||
|
||||
@@ -29,7 +29,8 @@ namespace PrivaPub.Models.Jobs
|
||||
FetchPreview,
|
||||
RollupDay,
|
||||
CrawlPlan,
|
||||
CrawlInstance
|
||||
CrawlInstance,
|
||||
SendRecovery
|
||||
}
|
||||
|
||||
public enum JobState
|
||||
|
||||
@@ -5,7 +5,9 @@ namespace PrivaPub.Models.User
|
||||
public class EmailRecovery : Entity
|
||||
{
|
||||
public string RootUserId { get; set; }
|
||||
public string RecoveryCode { get; set; }
|
||||
public string RecoveryCode { get; set; }//plaintext codes before 2026-10-04; migration _011 removed them, never written now
|
||||
public string CodeHash { get; set; }//sha-256 of the code, hex; the code itself exists only in the email
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public string RequestIP { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace PrivaPub.Models.User
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
public DateTime? CredentialsChangedAt { get; set; }//a /clientapi token issued before this is refused (JwtEvents)
|
||||
}
|
||||
|
||||
public class ContactItem
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace PrivaPub.Services
|
||||
Task<WebResult> UnbanUserAsync(UsersIds usersIds);
|
||||
|
||||
Task<WebResult> RemoveUserAsync(UsersIds usersIds);
|
||||
Task<WebResult> RemoveSelfAsync(string rootId, string password);
|
||||
|
||||
Task<WebResult> UpdateUserAsync(UserForm userEmailForm, string userId);
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ namespace PrivaPub.Services
|
||||
context.Fail("The account can no longer be used.");
|
||||
return;
|
||||
}
|
||||
// a password recovery ends every session made before it (RootSessions)
|
||||
if (root.CredentialsChangedAt is { } changed && context.SecurityToken.ValidFrom < changed.AddSeconds(-1))
|
||||
{
|
||||
context.Fail("The account's password was changed.");
|
||||
return;
|
||||
}
|
||||
if (context.Principal.Identity is not ClaimsIdentity identity)
|
||||
return;
|
||||
foreach (var policy in PolicyClaims)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using MailKit.Net.Smtp;
|
||||
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
using MimeKit;
|
||||
|
||||
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;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PrivaPub.Services
|
||||
{
|
||||
public sealed record RecoveryPayload(string RootUserId, string Host);
|
||||
|
||||
// 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.
|
||||
// The code exists only in the email: the database keeps its hash, for an hour.
|
||||
public class RecoveryJob : IJobHandler
|
||||
{
|
||||
public const string Host = "recovery";
|
||||
public static readonly TimeSpan CodeLifetime = TimeSpan.FromHours(1);
|
||||
|
||||
readonly AppConfigurationService _app;
|
||||
readonly IStringLocalizer<GenericRes> _localizer;
|
||||
readonly ILogger<RecoveryJob> _logger;
|
||||
|
||||
public RecoveryJob(AppConfigurationService app, IStringLocalizer<GenericRes> localizer, ILogger<RecoveryJob> logger)
|
||||
{
|
||||
_app = app;
|
||||
_localizer = localizer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public JobKind Kind => JobKind.SendRecovery;
|
||||
public int Concurrency => 1;
|
||||
public int MaxAttempts => 3;
|
||||
public int PerHostLimit => 1;
|
||||
|
||||
public static string Hash(string code) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(code ?? string.Empty)));
|
||||
|
||||
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<RecoveryPayload>(job.Payload);
|
||||
if (string.IsNullOrEmpty(payload?.RootUserId))
|
||||
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);
|
||||
if (user is not { DeletedAt: null, IsBanned: false } || string.IsNullOrEmpty(user.Email))
|
||||
return JobOutcome.Done;
|
||||
|
||||
var code = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
|
||||
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);
|
||||
|
||||
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
|
||||
{
|
||||
using var smtp = new SmtpClient();
|
||||
await smtp.ConnectAsync(email.SmtpServer, email.SmtpPort, email.UseSSL, token);
|
||||
await smtp.AuthenticateAsync(email.SmtpUsername, email.SmtpPassword, token);
|
||||
await smtp.SendAsync(message, token);
|
||||
await smtp.DisconnectAsync(quit: true, token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !token.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(ex, "The recovery email could not be sent");
|
||||
return JobOutcome.Retry("smtp");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Services
|
||||
{
|
||||
public interface IRootRemoval
|
||||
{
|
||||
Task<bool> Remove(string rootId, CancellationToken token);
|
||||
}
|
||||
|
||||
// Deleting a root deletes everything public it had (owner decision 2026-10-04): each persona, and each group a persona
|
||||
// owns, is announced deleted with a Delete of the actor to everyone who follows it, every member and everyone it
|
||||
// follows; their posts are emptied; their names stay reserved, and their documents answer 410 from then on. The root's
|
||||
// sessions end first, so nothing acts as it while it goes.
|
||||
public class RootRemoval : IRootRemoval
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IDeliveryService _delivery;
|
||||
readonly IRootSessions _sessions;
|
||||
|
||||
public RootRemoval(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, IRootSessions sessions)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_delivery = delivery;
|
||||
_sessions = sessions;
|
||||
}
|
||||
|
||||
public async Task<bool> Remove(string rootId, CancellationToken token)
|
||||
{
|
||||
var root = await _dbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token);
|
||||
if (root == default)
|
||||
return false;
|
||||
await _sessions.Revoke(root.ID, token);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var avatarIds = (await _dbEntities.RootToAvatars.Match(ra => ra.RootId == root.ID).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList();
|
||||
var avatars = await _dbEntities.Avatars.Match(a => avatarIds.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token);
|
||||
foreach (var avatar in avatars)
|
||||
{
|
||||
foreach (var group in await _dbEntities.Groups.Match(g => g.OwnerAvatarId == avatar.ID && !g.DeletionAt.HasValue).ExecuteAsync(token))
|
||||
{
|
||||
await Announce(_localActors.FromGroup(group), group.Members.Where(m => m.IsForeign).Select(m => m.AvatarId), token);
|
||||
await DB.Default.Update<GroupEntity>().MatchID(group.ID).Modify(g => g.DeletionAt, now).ExecuteAsync(token);
|
||||
}
|
||||
var persona = _localActors.FromAvatar(avatar);
|
||||
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == avatar.ID && !f.TargetIsLocal).ExecuteAsync(token))
|
||||
.Select(f => f.TargetActorURI);
|
||||
await Announce(persona, followed, token);
|
||||
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(a => a.DeletionAt, now).ExecuteAsync(token);
|
||||
await Empty(avatar.ID, now, token);
|
||||
}
|
||||
|
||||
await DB.Default.Update<RootUser>().MatchID(root.ID)
|
||||
.Modify(u => u.UserName, $"deleted-{root.ID}")//unique, so a second deletion never collides with the first
|
||||
.Modify(u => u.Email, null)
|
||||
.Modify(u => u.HashedPassword, null)
|
||||
.Modify(u => u.Policies, new List<string>())
|
||||
.Modify(u => u.IsBanned, false)
|
||||
.Modify(u => u.IsEmailValidated, false)
|
||||
.Modify(u => u.DeletedAt, now)
|
||||
.ExecuteAsync(token);
|
||||
await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == root.ID);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Delete{Actor}: to its followers' (shared) inboxes, and to the inboxes of the other accounts named
|
||||
async Task Announce(LocalActor actor, IEnumerable<string> others, CancellationToken token)
|
||||
{
|
||||
var inboxes = (await _delivery.FollowerInboxes(actor, token)).ToList();
|
||||
var named = others.Where(uri => !string.IsNullOrEmpty(uri)).Distinct().ToList();
|
||||
if (named.Count > 0)
|
||||
inboxes.AddRange((await _dbEntities.ForeignAvatars.Match(a => named.Contains(a.ActorURI)).ExecuteAsync(token))
|
||||
.Select(a => string.IsNullOrEmpty(a.SharedInboxURL) ? a.InboxURL : a.SharedInboxURL));
|
||||
await _delivery.Enqueue(actor, inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(), new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["id"] = actor.ActivityUri("delete-actor"),
|
||||
["type"] = "Delete",
|
||||
["actor"] = actor.Uri,
|
||||
["object"] = actor.Uri,
|
||||
["to"] = new JsonArray(ActivityPubRenderer.Public),
|
||||
["cc"] = new JsonArray(actor.Followers)
|
||||
}, token);
|
||||
}
|
||||
|
||||
// a persona's posts keep their ids and lose their content, like a single deleted post; a group writes none of its own
|
||||
static async Task Empty(string avatarId, DateTime now, CancellationToken token)
|
||||
{
|
||||
var postIds = (await DB.Default.Find<PostEntity>().Match(p => p.GroupUserId == avatarId && !p.IsFederatedCopy && !p.DeletedAt.HasValue)
|
||||
.Project(p => p.Include(x => x.ID)).ExecuteAsync(token)).Select(p => p.ID).ToList();
|
||||
if (postIds.Count == 0)
|
||||
return;
|
||||
await DB.Default.Update<PostEntity>().Match(p => postIds.Contains(p.ID))
|
||||
.Modify(p => p.DeletedAt, now)
|
||||
.Modify(p => p.Text, null)
|
||||
.Modify(p => p.ContentHtml, null)
|
||||
.Modify(p => p.Title, null)
|
||||
.Modify(p => p.SpoilerText, null)
|
||||
.Modify(p => p.Media, new List<Models.Post.PostMedia>())
|
||||
.Modify(p => p.Revisions, new List<Models.Post.PostRevision>())
|
||||
.ExecuteAsync(token);
|
||||
await DB.Default.DeleteAsync<TimelineEntry>(e => postIds.Contains(e.PostId) || postIds.Contains(e.ReblogOfPostId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Services
|
||||
{
|
||||
public interface IRootSessions
|
||||
{
|
||||
Task Revoke(string rootId, CancellationToken token);
|
||||
}
|
||||
|
||||
// Ends everything a root is signed in with: its /clientapi tokens, through CredentialsChangedAt (checked by JwtEvents),
|
||||
// and the Mastodon API tokens and authorizations of each of its personas. Used when its password is recovered and when
|
||||
// it is deleted.
|
||||
public class RootSessions : IRootSessions
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IOpenIddictTokenManager _tokens;
|
||||
readonly IOpenIddictAuthorizationManager _authorizations;
|
||||
|
||||
public RootSessions(DbEntities dbEntities, IOpenIddictTokenManager tokens, IOpenIddictAuthorizationManager authorizations)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_tokens = tokens;
|
||||
_authorizations = authorizations;
|
||||
}
|
||||
|
||||
public async Task Revoke(string rootId, CancellationToken token)
|
||||
{
|
||||
await DB.Default.Update<RootUser>().MatchID(rootId).Modify(u => u.CredentialsChangedAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||
foreach (var link in await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId).ExecuteAsync(token))
|
||||
{
|
||||
await _tokens.RevokeBySubjectAsync(link.AvatarId, token);
|
||||
await _authorizations.RevokeBySubjectAsync(link.AvatarId, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,12 @@ using PrivaPub.ClientModels.User;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
#pragma warning disable 8603
|
||||
#pragma warning disable 8625
|
||||
@@ -31,8 +34,14 @@ namespace PrivaPub.Services
|
||||
readonly ILogger<RootUsersService> Logger;
|
||||
readonly AppConfigurationService AppConfigurationService;
|
||||
readonly AuthTokenManager AuthTokenManager;
|
||||
readonly IJobQueue Jobs;
|
||||
readonly IRootSessions Sessions;
|
||||
readonly IRootRemoval Removal;
|
||||
|
||||
public RootUsersService(
|
||||
IJobQueue jobs,
|
||||
IRootSessions sessions,
|
||||
IRootRemoval removal,
|
||||
IStringLocalizer<GenericRes> localizer,
|
||||
ILogger<RootUsersService> logger,
|
||||
IPasswordHasher passwordHasher,
|
||||
@@ -40,6 +49,9 @@ namespace PrivaPub.Services
|
||||
AppConfigurationService appConfigurationService,
|
||||
AuthTokenManager authTokenManager)
|
||||
{
|
||||
Jobs = jobs;
|
||||
Sessions = sessions;
|
||||
Removal = removal;
|
||||
DbEntities = dbEntities;
|
||||
AuthTokenManager = authTokenManager;
|
||||
PasswordHasher = passwordHasher;
|
||||
@@ -48,6 +60,12 @@ namespace PrivaPub.Services
|
||||
AppConfigurationService = appConfigurationService;
|
||||
}
|
||||
|
||||
public const string NoMatch = "That username and password do not match.";
|
||||
static string _decoy;
|
||||
|
||||
// a hash nobody's password matches, so an unknown login costs the same hashing as a wrong password
|
||||
string Decoy => _decoy ??= PasswordHasher.Hash(Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)));
|
||||
|
||||
public async Task<WebResult> SignUpAsync(LoginForm signUpForm, string invitationCode = default,
|
||||
bool isPasswordRequired = false)
|
||||
{
|
||||
@@ -114,20 +132,16 @@ namespace PrivaPub.Services
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
// One answer, after the same work, whether the login is unknown, deleted or the password wrong: sign-in must not
|
||||
// tell anyone which logins exist. Only someone who knows the password learns the login is banned.
|
||||
loginForm.UserName = loginForm.UserName.ToLower();
|
||||
if (!await DbEntities.RootUsers.Match(u => u.UserName == loginForm.UserName && u.DeletedAt == null)
|
||||
.ExecuteAnyAsync())
|
||||
return result.Invalidate(Localizer["Username '{0}' not found.", loginForm.UserName]);
|
||||
|
||||
var user = await DbEntities.RootUsers.Match(u => u.UserName == loginForm.UserName).ExecuteFirstAsync();
|
||||
var user = await DbEntities.RootUsers.Match(u => u.UserName == loginForm.UserName && u.DeletedAt == null).ExecuteFirstAsync();
|
||||
var (verified, needsUpgrade) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, loginForm.Password);
|
||||
if (user?.HashedPassword == default || !verified)
|
||||
return result.Invalidate(Localizer[NoMatch]);
|
||||
if (user.IsBanned)
|
||||
return result.Invalidate(Localizer["User '{0}' banned.", user.UserName]);
|
||||
|
||||
var (verified, needsUpgrade) = PasswordHasher.Check(user.HashedPassword, loginForm.Password);
|
||||
|
||||
if (!verified)
|
||||
return result.Invalidate(Localizer["Wrong password."]);
|
||||
|
||||
if (needsUpgrade)
|
||||
result.ErrorMessage = Localizer["Needs upgrade!"];
|
||||
|
||||
@@ -261,24 +275,12 @@ namespace PrivaPub.Services
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var users = await DbEntities.RootUsers.Match(u => usersIds.UserIdList.Contains(u.ID) && u.DeletedAt == default).ExecuteAsync();
|
||||
if (users == null || users.Count == 0)
|
||||
var removed = 0;
|
||||
foreach (var id in usersIds.UserIdList.Distinct())
|
||||
if (await Removal.Remove(id, CancellationToken.None))
|
||||
removed++;
|
||||
if (removed == 0)
|
||||
return result.Invalidate(Localizer["User already deleted."]);
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
user.Email = Localizer["Deleted user"];
|
||||
user.HashedPassword = null;
|
||||
user.Policies.Clear();
|
||||
user.IsBanned = false;
|
||||
user.IsEmailValidated = false;
|
||||
//user.TempSecret = null;
|
||||
user.UserName = Localizer["Deleted user"];
|
||||
user.DeletedAt = DateTime.UtcNow;
|
||||
|
||||
await DB.Default.SaveAsync(user);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -288,6 +290,26 @@ namespace PrivaPub.Services
|
||||
}
|
||||
}
|
||||
|
||||
// A root deletes itself only with its password, so a stolen session cannot.
|
||||
public async Task<WebResult> RemoveSelfAsync(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[NoMatch], StatusCodes.Status403Forbidden);
|
||||
await Removal.Remove(user.ID, CancellationToken.None);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveSelfAsync)}()");
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebResult> BanUserAsync(UsersIds usersIds)
|
||||
{
|
||||
var result = new WebResult();
|
||||
@@ -354,141 +376,22 @@ namespace PrivaPub.Services
|
||||
}
|
||||
}
|
||||
|
||||
public const string RecoverySent = "If that account has an email address, a recovery link is on its way. It works for one hour.";
|
||||
|
||||
// The same answer, after the same work, whether the account exists, has an email or can be written to: recovery must
|
||||
// not tell anyone which logins or addresses exist. RecoveryJob does the rest, out of the request.
|
||||
public async Task<WebResult> SetupAndSendRecoveryEmail(PasswordRecoveryForm passwordRecoveryForm, string host)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var usernameExists = false;
|
||||
if (passwordRecoveryForm.IsEmailDisabled)
|
||||
{
|
||||
if (!await DbEntities.RootUsers.Match(u => u.UserName == passwordRecoveryForm.UserName && u.DeletedAt == null)
|
||||
.ExecuteAnyAsync())
|
||||
return result.Invalidate(Localizer["Username '{0}' not found.", passwordRecoveryForm.UserName],
|
||||
StatusCodes.Status404NotFound);
|
||||
usernameExists = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.DeletedAt == null)
|
||||
.ExecuteAnyAsync())
|
||||
return result.Invalidate(Localizer["Username '{0}' not found.", passwordRecoveryForm.UserName],
|
||||
StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
var user = default(RootUser);
|
||||
if (usernameExists)
|
||||
user = await DbEntities.RootUsers.Match(u => u.UserName == passwordRecoveryForm.UserName).ExecuteFirstAsync();
|
||||
else
|
||||
user = await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email).ExecuteFirstAsync();
|
||||
|
||||
if (string.IsNullOrEmpty(user.Email))
|
||||
return result.Invalidate(Localizer["This User doesn't have an email, no way to recover."],
|
||||
StatusCodes.Status423Locked);
|
||||
|
||||
var emailRecovery = await DbEntities.EmailRecoveries
|
||||
.Match(er => er.RootUserId == user.ID).ExecuteFirstAsync();
|
||||
|
||||
if (emailRecovery is null)
|
||||
{
|
||||
emailRecovery = new()
|
||||
{
|
||||
RootUserId = user.ID
|
||||
};
|
||||
await DB.Default.SaveAsync(emailRecovery);
|
||||
}
|
||||
|
||||
using var recoveryCodeGenerator = new Password(true, true, true, false, 127);
|
||||
emailRecovery.RecoveryCode = recoveryCodeGenerator.Next();
|
||||
await DB.Default.SaveAsync(emailRecovery);
|
||||
|
||||
using (var smtpClient = new SmtpClient())
|
||||
{
|
||||
try
|
||||
{
|
||||
await smtpClient.ConnectAsync(AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpServer, AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpPort,
|
||||
AppConfigurationService.AppConfiguration.EmailConfiguration.UseSSL);
|
||||
if (!smtpClient.IsConnected)
|
||||
{
|
||||
Logger.LogError($"Failed to connect to the SMTP server({AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpServer}).");
|
||||
return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"Error at connection to the SMTP server({AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpServer}).");
|
||||
return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable, exception: ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await smtpClient.AuthenticateAsync(AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername, AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpPassword);
|
||||
if (!smtpClient.IsAuthenticated)
|
||||
{
|
||||
Logger.LogError($"Failed SMTP authentication of {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}.");
|
||||
return result.Invalidate(Localizer["Failed to send email."],
|
||||
StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"Failed SMTP authentication of {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}.");
|
||||
return result.Invalidate(Localizer["Failed to send email."],
|
||||
StatusCodes.Status503ServiceUnavailable, exception: ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var toParsed = await smtpClient.VerifyAsync(user.Email);
|
||||
if (toParsed == null)
|
||||
return result.Invalidate(Localizer["Invalid email."], StatusCodes.Status400BadRequest);
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
"SMTP operation canceled at email verification: {Error}", ex.Message);
|
||||
}
|
||||
catch (SmtpCommandException ex)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
"SMTP command exception at email verification: {Error}", ex.Message);
|
||||
}
|
||||
catch (SmtpProtocolException ex)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
"SMTP protocol exception at email verification: {Error}", ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning("Exception at email verification: {Error}", ex.Message);
|
||||
}
|
||||
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress("PrivaPub", AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername));
|
||||
message.To.Add(MailboxAddress.Parse(user.Email));
|
||||
message.Subject = Localizer["PrivaPub - Password recovery link"];
|
||||
message.Body = new TextPart("plain")
|
||||
{
|
||||
Text = string.Format(Localizer[@"Hey {0},
|
||||
|
||||
Eugene from collAnon, following is the password recovery link:
|
||||
{1}
|
||||
|
||||
-- Eugene"], user.UserName, $"{host}/password-recovery?rc={emailRecovery.RecoveryCode}")
|
||||
};
|
||||
try
|
||||
{
|
||||
await smtpClient.SendAsync(message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"Error at email sending to {user.Email} from {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}.");
|
||||
return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable, exception: ex);
|
||||
}
|
||||
|
||||
await smtpClient.DisconnectAsync(quit: true);
|
||||
}
|
||||
|
||||
var userName = passwordRecoveryForm.UserName?.ToLowerInvariant();
|
||||
var user = passwordRecoveryForm.IsEmailDisabled
|
||||
? 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 Jobs.Enqueue(JobKind.SendRecovery, JsonSerializer.Serialize(new RecoveryPayload(user?.ID, host)), RecoveryJob.Host,
|
||||
dedupeKey: default, CancellationToken.None);
|
||||
result.Data = Localizer[RecoverySent].Value;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -498,16 +401,19 @@ Eugene from collAnon, following is the password recovery link:
|
||||
}
|
||||
}
|
||||
|
||||
Task<EmailRecovery> LiveRecovery(string recoveryCode)
|
||||
{
|
||||
var hash = RecoveryJob.Hash(recoveryCode);
|
||||
var now = DateTime.UtcNow;
|
||||
return DbEntities.EmailRecoveries.Match(er => er.CodeHash == hash && er.ExpiresAt > now).ExecuteFirstAsync();
|
||||
}
|
||||
|
||||
public async Task<WebResult> IsValidRecoveryCode(string recoveryCode)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var isValidRecoveryCode = await DbEntities.EmailRecoveries
|
||||
.Match(er => er.RecoveryCode == recoveryCode).ExecuteAnyAsync();
|
||||
|
||||
result.Data = isValidRecoveryCode;
|
||||
|
||||
result.Data = await LiveRecovery(recoveryCode) != default;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -516,33 +422,24 @@ Eugene from collAnon, following is the password recovery link:
|
||||
}
|
||||
}
|
||||
|
||||
// A recovered password ends every session the root had: whoever had them may be why it was recovered.
|
||||
public async Task<WebResult> ChangePassword(NewPasswordForm newPasswordForm)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var isValidRecoveryCode = await DbEntities.EmailRecoveries
|
||||
.Match(er => er.RecoveryCode == newPasswordForm.RecoveryCode).ExecuteAnyAsync();
|
||||
if (!isValidRecoveryCode)
|
||||
var recovery = await LiveRecovery(newPasswordForm.RecoveryCode);
|
||||
var user = recovery == default ? default
|
||||
: await DbEntities.RootUsers.MatchID(recovery.RootUserId).Match(u => u.DeletedAt == null && !u.IsBanned).ExecuteFirstAsync();
|
||||
if (user == default)
|
||||
return result.Invalidate(Localizer["Invalid recovery code."], StatusCodes.Status404NotFound);
|
||||
|
||||
var emailRecovery = await DbEntities.EmailRecoveries
|
||||
.Match(er => er.RecoveryCode == newPasswordForm.RecoveryCode)
|
||||
.Project(er => er.Include(nameof(EmailRecovery.RootUserId)))
|
||||
.ExecuteFirstAsync();
|
||||
|
||||
if (emailRecovery is null || emailRecovery.RootUserId is null)
|
||||
return result.Invalidate(Localizer["User not found."]);
|
||||
|
||||
var newHashedPassword = PasswordHasher.Hash(newPasswordForm.NewPassword);
|
||||
|
||||
_ = await DB.Default.Update<RootUser>()
|
||||
.Match(u => u.ID == emailRecovery.RootUserId && u.DeletedAt == null)
|
||||
.Modify(u => u.HashedPassword, newHashedPassword)
|
||||
await DB.Default.Update<RootUser>().MatchID(user.ID)
|
||||
.Modify(u => u.HashedPassword, PasswordHasher.Hash(newPasswordForm.NewPassword))
|
||||
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
|
||||
.ExecuteAsync();
|
||||
|
||||
_ = await DB.Default.DeleteAsync<EmailRecovery>(er => er.RecoveryCode == newPasswordForm.RecoveryCode);
|
||||
|
||||
await DB.Default.DeleteAsync<EmailRecovery>(er => er.RootUserId == user.ID);
|
||||
await Sessions.Revoke(user.ID, CancellationToken.None);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -44,6 +44,7 @@ namespace PrivaPub.StaticServices
|
||||
|
||||
var token = new JwtSecurityToken(issuer: Configuration["AppConfiguration:Jwt:Issuer"], audience: Configuration["AppConfiguration:Jwt:Audience"],
|
||||
claims: claims,
|
||||
notBefore: DateTime.UtcNow,//its issue time, which JwtEvents compares with RootUser.CredentialsChangedAt
|
||||
expires: expiration,
|
||||
signingCredentials: new(securityKey, SecurityAlgorithms.HmacSha512)
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ namespace PrivaPub.StaticServices
|
||||
using (var algorithm = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA512))
|
||||
{
|
||||
var keyToCheck = algorithm.GetBytes(KeySize);
|
||||
var verified = keyToCheck.SequenceEqual(key);
|
||||
var verified = CryptographicOperations.FixedTimeEquals(keyToCheck, key);
|
||||
return (verified, needsUpgrade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,8 +165,10 @@ until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$ci
|
||||
curl -s -o /dev/null -X POST $P/clientapi/post/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
||||
-d "{\"avatarId\":\"$alice_id\",\"text\":\"only the circle on GoToSocial sees this\",\"groupId\":\"$circle\"}"
|
||||
circle_uri=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Post.findOne({Text:/only the circle on GoToSocial/}).ObjectURI)')
|
||||
# GoToSocial keeps no post addressed only to a collection it does not know, so each member's copy names that member
|
||||
until_true 15 '[ "$(on_gts "$circle_uri" | j "print(len(d[\"statuses\"]))")" = "1" ]' \
|
||||
# GoToSocial files a post for neither the public nor the author's followers as a direct message (as it does our DMs),
|
||||
# shown only to the accounts it mentions; so each member's copy names and silently mentions that member. Like a DM it
|
||||
# is then in gtsuser's conversations, never in a search by URI.
|
||||
until_true 15 'gcurl -s -H "$GH" "$G/api/v1/conversations" | grep -q "only the circle on GoToSocial sees this"' \
|
||||
&& ok "a circle post, naming its member, reaches its GoToSocial member" || ko "circle post missing on GoToSocial"
|
||||
[ "$(pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$circle_uri")" = "404" ] && ok "the circle post is not served unsigned" || ko "circle post served unsigned"
|
||||
|
||||
|
||||
Reference in new issue
Block a user