diff --git a/CLAUDE.md b/CLAUDE.md index b8b0f8c..3f9931e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/PrivaPub.ClientModels/User/RemoveSelfForm.cs b/PrivaPub.ClientModels/User/RemoveSelfForm.cs new file mode 100644 index 0000000..fe7ebec --- /dev/null +++ b/PrivaPub.ClientModels/User/RemoveSelfForm.cs @@ -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; } + } +} diff --git a/PrivaPub.Tests/Domain/ContentRendererTests.cs b/PrivaPub.Tests/Domain/ContentRendererTests.cs index 2861f32..59846b0 100644 --- a/PrivaPub.Tests/Domain/ContentRendererTests.cs +++ b/PrivaPub.Tests/Domain/ContentRendererTests.cs @@ -60,6 +60,7 @@ namespace PrivaPub.Tests.Domain { public string BaseAddress => Base; + public Task Gone(string userName, CancellationToken token) => Task.FromResult(default); public Task FindByUserName(string userName, CancellationToken token) => Task.FromResult(userName.Equals("alice", StringComparison.OrdinalIgnoreCase) ? new LocalActor { Id = "a1", UserName = "alice", BaseAddress = Base, Kind = LocalActorKind.Person } diff --git a/PrivaPub.Tests/Federation/GroupTests.cs b/PrivaPub.Tests/Federation/GroupTests.cs index 36592c8..5b1304f 100644 --- a/PrivaPub.Tests/Federation/GroupTests.cs +++ b/PrivaPub.Tests/Federation/GroupTests.cs @@ -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())); Assert.Equal(new[] { member.Id }, create["object"]!["cc"]!.AsArray().Select(t => t!.GetValue())); + Assert.Contains(create["object"]!["tag"]!.AsArray(), t => t!["type"]!.GetValue() == "Mention" && t["href"]!.GetValue() == 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() == "Announce")); diff --git a/PrivaPub.Tests/Http/ClientApiAccountsTests.cs b/PrivaPub.Tests/Http/ClientApiAccountsTests.cs index 794be5b..8b000e3 100644 --- a/PrivaPub.Tests/Http/ClientApiAccountsTests.cs +++ b/PrivaPub.Tests/Http/ClientApiAccountsTests.cs @@ -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()); + // 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(); + 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().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().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()); - 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().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().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] diff --git a/PrivaPub.Tests/Http/RootRemovalTests.cs b/PrivaPub.Tests/Http/RootRemovalTests.cs new file mode 100644 index 0000000..59162e2 --- /dev/null +++ b/PrivaPub.Tests/Http/RootRemovalTests.cs @@ -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 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(); + 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() == "Delete"); + Assert.Equal(persona.ActorUri(), toFollower["object"]!.GetValue()); + Assert.Equal(persona.ActorUri(), toFollower["actor"]!.GetValue()); + var groupName = group["userName"]!.GetValue(); + Assert.Contains(await Jobs.Deliveries(member.Id + "/inbox", since, token), + d => d["type"]!.GetValue() == "Delete" && d["object"]!.GetValue() == $"{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()); + 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().MatchID(post.ID).ExecuteFirstAsync(token); + Assert.NotNull(stored.DeletedAt); + Assert.Null(stored.ContentHtml); + Assert.NotNull((await DB.Default.Find().MatchID(groupId).ExecuteFirstAsync(token)).DeletionAt); + var removed = await DB.Default.Find().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().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().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().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/clientapi/user/sniff/again", token)).StatusCode); + } + } +} diff --git a/PrivaPub/Controllers/ClientToServer/RootUserController.cs b/PrivaPub/Controllers/ClientToServer/RootUserController.cs index c7a466a..40bc99a 100644 --- a/PrivaPub/Controllers/ClientToServer/RootUserController.cs +++ b/PrivaPub/Controllers/ClientToServer/RootUserController.cs @@ -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 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 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 diff --git a/PrivaPub/Federation/Actors/LocalActorService.cs b/PrivaPub/Federation/Actors/LocalActorService.cs index 457baaf..5c03541 100644 --- a/PrivaPub/Federation/Actors/LocalActorService.cs +++ b/PrivaPub/Federation/Actors/LocalActorService.cs @@ -62,6 +62,7 @@ namespace PrivaPub.Federation.Actors { string BaseAddress { get; } Task FindByUserName(string userName, CancellationToken token); + Task Gone(string userName, CancellationToken token); Task FindById(LocalActorKind kind, string id, CancellationToken token); Task FindByUri(string actorUri, CancellationToken token); Task 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 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 FindById(LocalActorKind kind, string id, CancellationToken token) { switch (kind) diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index 972d447..b224781 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -53,6 +53,20 @@ namespace PrivaPub.Federation.Controllers public async Task 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 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() : 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) diff --git a/PrivaPub/Federation/Controllers/WellKnownController.cs b/PrivaPub/Federation/Controllers/WellKnownController.cs index 991e8b0..0a8c452 100644 --- a/PrivaPub/Federation/Controllers/WellKnownController.cs +++ b/PrivaPub/Federation/Controllers/WellKnownController.cs @@ -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); diff --git a/PrivaPub/Federation/Outbox/OutboxPublisher.cs b/PrivaPub/Federation/Outbox/OutboxPublisher.cs index dfbaa44..e0fba61 100644 --- a/PrivaPub/Federation/Outbox/OutboxPublisher.cs +++ b/PrivaPub/Federation/Outbox/OutboxPublisher.cs @@ -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> 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> CircleRecipients(string groupId, CancellationToken token) + async Task> 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(); 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(); 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 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 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 members) + static void Name(JsonObject node, IReadOnlyList 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() == member)) - cc.Add(member); + { + if (!cc.Any(c => c?.GetValue() == member.ActorURI)) + cc.Add(member.ActorURI); + if (mention && !tags.Any(t => t?["href"]?.GetValue() == 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); diff --git a/PrivaPub/Infrastructure/Data/Migrations/_011_recovery_codes_are_hashed.cs b/PrivaPub/Infrastructure/Data/Migrations/_011_recovery_codes_are_hashed.cs new file mode 100644 index 0000000..1d43e87 --- /dev/null +++ b/PrivaPub/Infrastructure/Data/Migrations/_011_recovery_codes_are_hashed.cs @@ -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(r => r.CodeHash == null); + } +} diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index ed328a3..e5442b1 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -99,6 +99,7 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(services => services.GetRequiredService()) @@ -182,6 +183,8 @@ namespace PrivaPub.Middleware return service .AddTransient() .AddTransient() + .AddScoped() + .AddScoped() .AddTransient() .AddTransient() .AddTransient() diff --git a/PrivaPub/Models/Jobs/Job.cs b/PrivaPub/Models/Jobs/Job.cs index 936166b..1b1ff26 100644 --- a/PrivaPub/Models/Jobs/Job.cs +++ b/PrivaPub/Models/Jobs/Job.cs @@ -29,7 +29,8 @@ namespace PrivaPub.Models.Jobs FetchPreview, RollupDay, CrawlPlan, - CrawlInstance + CrawlInstance, + SendRecovery } public enum JobState diff --git a/PrivaPub/Models/User/EmailRecovery.cs b/PrivaPub/Models/User/EmailRecovery.cs index 8c85076..7b1cd2e 100644 --- a/PrivaPub/Models/User/EmailRecovery.cs +++ b/PrivaPub/Models/User/EmailRecovery.cs @@ -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; } diff --git a/PrivaPub/Models/User/RootUser.cs b/PrivaPub/Models/User/RootUser.cs index e9b3059..19ebd2e 100644 --- a/PrivaPub/Models/User/RootUser.cs +++ b/PrivaPub/Models/User/RootUser.cs @@ -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 diff --git a/PrivaPub/Services/IRootUsersService.cs b/PrivaPub/Services/IRootUsersService.cs index 47c1cd0..6fd9b5a 100644 --- a/PrivaPub/Services/IRootUsersService.cs +++ b/PrivaPub/Services/IRootUsersService.cs @@ -16,6 +16,7 @@ namespace PrivaPub.Services Task UnbanUserAsync(UsersIds usersIds); Task RemoveUserAsync(UsersIds usersIds); + Task RemoveSelfAsync(string rootId, string password); Task UpdateUserAsync(UserForm userEmailForm, string userId); diff --git a/PrivaPub/Services/JwtEvents.cs b/PrivaPub/Services/JwtEvents.cs index 2a2fe72..ee7c1c6 100644 --- a/PrivaPub/Services/JwtEvents.cs +++ b/PrivaPub/Services/JwtEvents.cs @@ -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) diff --git a/PrivaPub/Services/RecoveryJob.cs b/PrivaPub/Services/RecoveryJob.cs new file mode 100644 index 0000000..c876cc7 --- /dev/null +++ b/PrivaPub/Services/RecoveryJob.cs @@ -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 _localizer; + readonly ILogger _logger; + + public RecoveryJob(AppConfigurationService app, IStringLocalizer localizer, ILogger 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 Handle(Job job, CancellationToken token) + { + var payload = JsonSerializer.Deserialize(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().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(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"); + } + } + } +} diff --git a/PrivaPub/Services/RootRemoval.cs b/PrivaPub/Services/RootRemoval.cs new file mode 100644 index 0000000..dfee7ee --- /dev/null +++ b/PrivaPub/Services/RootRemoval.cs @@ -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 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 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().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().MatchID(avatar.ID).Modify(a => a.DeletionAt, now).ExecuteAsync(token); + await Empty(avatar.ID, now, token); + } + + await DB.Default.Update().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()) + .Modify(u => u.IsBanned, false) + .Modify(u => u.IsEmailValidated, false) + .Modify(u => u.DeletedAt, now) + .ExecuteAsync(token); + await DB.Default.DeleteAsync(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 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().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().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()) + .Modify(p => p.Revisions, new List()) + .ExecuteAsync(token); + await DB.Default.DeleteAsync(e => postIds.Contains(e.PostId) || postIds.Contains(e.ReblogOfPostId)); + } + } +} diff --git a/PrivaPub/Services/RootSessions.cs b/PrivaPub/Services/RootSessions.cs new file mode 100644 index 0000000..eab0d2b --- /dev/null +++ b/PrivaPub/Services/RootSessions.cs @@ -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().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); + } + } + } +} diff --git a/PrivaPub/Services/RootUsersService.cs b/PrivaPub/Services/RootUsersService.cs index 46d5550..c9f4e2c 100644 --- a/PrivaPub/Services/RootUsersService.cs +++ b/PrivaPub/Services/RootUsersService.cs @@ -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 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 localizer, ILogger 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 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 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 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 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 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 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 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() - .Match(u => u.ID == emailRecovery.RootUserId && u.DeletedAt == null) - .Modify(u => u.HashedPassword, newHashedPassword) + await DB.Default.Update().MatchID(user.ID) + .Modify(u => u.HashedPassword, PasswordHasher.Hash(newPasswordForm.NewPassword)) + .Modify(u => u.UpdatedAt, DateTime.UtcNow) .ExecuteAsync(); - - _ = await DB.Default.DeleteAsync(er => er.RecoveryCode == newPasswordForm.RecoveryCode); - + await DB.Default.DeleteAsync(er => er.RootUserId == user.ID); + await Sessions.Revoke(user.ID, CancellationToken.None); return result; } catch (Exception ex) diff --git a/PrivaPub/StaticServices/AuthTokenManager.cs b/PrivaPub/StaticServices/AuthTokenManager.cs index aabb74a..5d4e1aa 100644 --- a/PrivaPub/StaticServices/AuthTokenManager.cs +++ b/PrivaPub/StaticServices/AuthTokenManager.cs @@ -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) ); diff --git a/PrivaPub/StaticServices/PasswordHasher.cs b/PrivaPub/StaticServices/PasswordHasher.cs index e6500d2..eaa88b1 100644 --- a/PrivaPub/StaticServices/PasswordHasher.cs +++ b/PrivaPub/StaticServices/PasswordHasher.cs @@ -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); } } diff --git a/tools/pasture/scenarios/gts.sh b/tools/pasture/scenarios/gts.sh index 98111f5..624cc78 100644 --- a/tools/pasture/scenarios/gts.sh +++ b/tools/pasture/scenarios/gts.sh @@ -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"