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
465 lines
16 KiB
C#
465 lines
16 KiB
C#
using MailKit.Net.Smtp;
|
|
|
|
using Microsoft.Extensions.Localization;
|
|
|
|
using MimeKit;
|
|
|
|
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using PasswordGenerator;
|
|
|
|
using PrivaPub.ClientModels;
|
|
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
|
|
|
|
namespace PrivaPub.Services
|
|
{
|
|
public class RootUsersService : IRootUsersService
|
|
{
|
|
readonly DbEntities DbEntities;
|
|
readonly IPasswordHasher PasswordHasher;
|
|
readonly IStringLocalizer Localizer;
|
|
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,
|
|
DbEntities dbEntities,
|
|
AppConfigurationService appConfigurationService,
|
|
AuthTokenManager authTokenManager)
|
|
{
|
|
Jobs = jobs;
|
|
Sessions = sessions;
|
|
Removal = removal;
|
|
DbEntities = dbEntities;
|
|
AuthTokenManager = authTokenManager;
|
|
PasswordHasher = passwordHasher;
|
|
Localizer = localizer;
|
|
Logger = logger;
|
|
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)
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
signUpForm.UserName = signUpForm.UserName.ToLower();
|
|
if (await DbEntities.RootUsers.Match(u => u.UserName == signUpForm.UserName).ExecuteAnyAsync())
|
|
return result.Invalidate(Localizer["Username '{0}' already taken.", signUpForm.UserName]);
|
|
|
|
var signUpPasswordHashed = PasswordHasher.Hash(signUpForm.Password);
|
|
var newUser = new RootUser
|
|
{
|
|
UserName = signUpForm.UserName,
|
|
HashedPassword = signUpPasswordHashed
|
|
};
|
|
|
|
var cultureLanguage = CultureInfo.CurrentCulture.TwoLetterISOLanguageName;
|
|
var language = await DbEntities.Languages.Match(l => l.International2Code == cultureLanguage).ExecuteFirstAsync();
|
|
newUser.Settings = new RootUserSettings
|
|
{
|
|
LanguageCode = language?.International2Code ?? "en",
|
|
LightThemeIndexColour = signUpForm.LightThemeIndexColour,
|
|
DarkThemeIndexColour = signUpForm.DarkThemeIndexColour,
|
|
IconsThemeIndexColour = signUpForm.IconsThemeIndexColour,
|
|
ThemeIsDarkGray = signUpForm.ThemeIsDarkGray,
|
|
ThemeIsLightGray = signUpForm.ThemeIsLightGray,
|
|
PreferSystemTheming = signUpForm.PreferSystemTheming,
|
|
ThemeIsDarkMode = signUpForm.ThemeIsDarkMode
|
|
};
|
|
await DB.Default.SaveAsync(newUser);
|
|
|
|
//if (!string.IsNullOrEmpty(invitationCode))
|
|
//{
|
|
// if (isPasswordRequired)
|
|
// result = await DiscussionService.InviteUserToDiscussion(new PwDiscussionPreviewForm
|
|
// {
|
|
// InvitationCode = invitationCode,
|
|
// Password = signUpForm.InvitationPassword
|
|
// }, newUser.ID);
|
|
// else
|
|
// result = await DiscussionService.InviteUserToDiscussion(new NoPwDiscussionPreviewForm
|
|
// {
|
|
// InvitationCode = invitationCode,
|
|
// }, newUser.ID);
|
|
// if (!result.IsValid)
|
|
// return result;
|
|
//}
|
|
|
|
result.Data = (newUser, ToViewSettings(newUser.Settings));
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SignUpAsync)}()");
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
public async Task<WebResult> LoginAsync(LoginForm loginForm, string invitationCode = default,
|
|
bool isPasswordRequired = false)
|
|
{
|
|
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();
|
|
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]);
|
|
|
|
if (needsUpgrade)
|
|
result.ErrorMessage = Localizer["Needs upgrade!"];
|
|
|
|
var userSettingsResult = await GetUserSettingsAsync(user.ID, loginForm);
|
|
var userSettings = (ViewUserSettings)userSettingsResult.Data;
|
|
|
|
//if (!string.IsNullOrEmpty(invitationCode))
|
|
//{
|
|
// if (isPasswordRequired)
|
|
// result = await DiscussionService.InviteUserToDiscussion(new PwDiscussionPreviewForm
|
|
// {
|
|
// InvitationCode = invitationCode,
|
|
// Password = loginForm.InvitationPassword
|
|
// }, user.ID);
|
|
// else
|
|
// result = await DiscussionService.InviteUserToDiscussion(new NoPwDiscussionPreviewForm
|
|
// {
|
|
// InvitationCode = invitationCode,
|
|
// }, user.ID);
|
|
// if (!result.IsValid)
|
|
// return result;
|
|
//}
|
|
|
|
result.Data = (user, userSettings);
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(LoginAsync)}()");
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
public async Task<WebResult> UpdateUserAsync(UserForm userForm, string userId)
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
var currentUser = await DbEntities.RootUsers.Match(u => u.ID == userId).ExecuteFirstAsync();
|
|
if (!string.IsNullOrEmpty(userForm.Email) && currentUser.Email != userForm.Email)
|
|
{
|
|
var emailAlreadyUsed = await DbEntities.RootUsers.Match(u => u.Email == userForm.Email).ExecuteAnyAsync();
|
|
if (emailAlreadyUsed)
|
|
return result.Invalidate(Localizer["Email '{0}' already taken.", userForm.Email]);
|
|
}
|
|
|
|
await DB.Default.Update<RootUser>()
|
|
.Match(u => u.ID == userId)
|
|
.Modify(u => u.Email, userForm.Email)
|
|
.ExecuteAsync();
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserAsync)}()");
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
public async Task<WebResult> UpdateUserSettingsAsync(ViewUserSettings userSettings, string userId)
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
var isSupportedLanguage = AppConfigurationService.AppConfiguration.SupportedLanguages.Contains(userSettings.LanguageCode);
|
|
if (!isSupportedLanguage)
|
|
return result.Invalidate(Localizer["Language code '{0}' unsupported.", userSettings.LanguageCode]);
|
|
|
|
var languageCodeExists = await DbEntities.Languages
|
|
.Match(l => l.International2Code == userSettings.LanguageCode).ExecuteAnyAsync();
|
|
if (!languageCodeExists)
|
|
return result.Invalidate(Localizer["Language code '{0}' doesn't exist.", userSettings.LanguageCode]);
|
|
|
|
var language = await DbEntities.Languages.Match(l => l.International2Code == userSettings.LanguageCode)
|
|
.ExecuteFirstAsync();
|
|
_ = await DB.Default.Update<RootUser>()
|
|
.MatchID(userId)
|
|
.Modify(u => u.Settings.LanguageCode, language.International2Code)
|
|
.Modify(u => u.Settings.LightThemeIndexColour, userSettings.LightThemeIndexColour)
|
|
.Modify(u => u.Settings.DarkThemeIndexColour, userSettings.DarkThemeIndexColour)
|
|
.Modify(u => u.Settings.PreferSystemTheming, userSettings.PreferSystemTheming)
|
|
.Modify(u => u.Settings.ThemeIsDarkMode, userSettings.ThemeIsDarkMode)
|
|
.Modify(u => u.Settings.ThemeIsDarkGray, userSettings.ThemeIsDarkGray)
|
|
.Modify(u => u.Settings.ThemeIsLightGray, userSettings.ThemeIsLightGray)
|
|
.Modify(u => u.Settings.IconsThemeIndexColour, userSettings.IconsThemeIndexColour)
|
|
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
|
|
.ExecuteAsync();
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserSettingsAsync)}()");
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
public async Task<WebResult> UpdateUserPasswordAsync(UserPasswordForm userPasswordForm, string userId)
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
var user = await DbEntities.RootUsers.Match(u => u.ID == userId && u.DeletedAt == null).ExecuteFirstAsync();
|
|
|
|
if (user == null)
|
|
return result.Invalidate(Localizer["Username '{0}' not found.", userId]);
|
|
|
|
var (verified, needsUpgrade) = PasswordHasher.Check(user.HashedPassword, userPasswordForm.OldPassword);
|
|
|
|
if (!verified)
|
|
return result.Invalidate(Localizer["Wrong password."]);
|
|
|
|
var newPasswordHashed = PasswordHasher.Hash(userPasswordForm.NewPassword);
|
|
user.HashedPassword = newPasswordHashed;
|
|
user.UpdatedAt = DateTime.UtcNow;
|
|
await DB.Default.SaveAsync(user);
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserPasswordAsync)}()");
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
public async Task<WebResult> RemoveUserAsync(UsersIds usersIds)
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
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."]);
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveUserAsync)}()");
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
try
|
|
{
|
|
await DB.Default.Update<RootUser>()
|
|
.Match(u => usersIds.UserIdList.Contains(u.ID))
|
|
.Modify(u => u.IsBanned, true)
|
|
.ExecuteAsync();
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(BanUserAsync)}()");
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
public async Task<WebResult> UnbanUserAsync(UsersIds usersIds)
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
await DB.Default.Update<RootUser>()
|
|
.Match(u => usersIds.UserIdList.Contains(u.ID))
|
|
.Modify(u => u.IsBanned, false)
|
|
.ExecuteAsync();
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UnbanUserAsync)}()");
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
public async Task<WebResult> GetUserSettingsAsync(string userId, LoginForm loginForm = default)
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
var user = await DbEntities.RootUsers.MatchID(userId).ExecuteFirstAsync();
|
|
if (user == default)
|
|
return result.Invalidate(Localizer["User not found."], StatusCodes.Status404NotFound);
|
|
|
|
if (loginForm != default && loginForm.ThemeIsDarkMode != user.Settings.ThemeIsDarkMode)
|
|
{
|
|
user.Settings.ThemeIsDarkMode = loginForm.ThemeIsDarkMode;
|
|
await DB.Default.Update<RootUser>()
|
|
.MatchID(userId)
|
|
.Modify(u => u.Settings.ThemeIsDarkMode, loginForm.ThemeIsDarkMode)
|
|
.ExecuteAsync();
|
|
}
|
|
|
|
result.Data = ToViewSettings(user.Settings);
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(GetUserSettingsAsync)}()");
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SetupAndSendRecoveryEmail)}()");
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
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
|
|
{
|
|
result.Data = await LiveRecovery(recoveryCode) != default;
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
// 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 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);
|
|
|
|
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.RootUserId == user.ID);
|
|
await Sessions.Revoke(user.ID, CancellationToken.None);
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(ChangePassword)}()");
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
static ViewUserSettings ToViewSettings(RootUserSettings settings) => new()
|
|
{
|
|
LanguageCode = settings.LanguageCode,
|
|
LightThemeIndexColour = settings.LightThemeIndexColour,
|
|
DarkThemeIndexColour = settings.DarkThemeIndexColour,
|
|
PreferSystemTheming = settings.PreferSystemTheming,
|
|
ThemeIsDarkMode = settings.ThemeIsDarkMode,
|
|
IconsThemeIndexColour = settings.IconsThemeIndexColour,
|
|
ThemeIsDarkGray = settings.ThemeIsDarkGray,
|
|
ThemeIsLightGray = settings.ThemeIsLightGray
|
|
};
|
|
}
|
|
}
|