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
464 lines
16 KiB
C#
464 lines
16 KiB
C#
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.Extensions.Localization;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
using PrivaPub.ClientModels;
|
|
using PrivaPub.ClientModels.Resources;
|
|
using PrivaPub.ClientModels.Group;
|
|
using PrivaPub.ClientModels.User;
|
|
using PrivaPub.ClientModels.User.Avatar;
|
|
using PrivaPub.Extensions;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Infrastructure;
|
|
using PrivaPub.Models;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Resources;
|
|
using PrivaPub.Services;
|
|
using PrivaPub.Services.ClientToServer.Private;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.ComponentModel.DataAnnotations;
|
|
|
|
namespace PrivaPub.Controllers.ClientToServer
|
|
{
|
|
[ApiController,
|
|
Route("clientapi/user")]
|
|
public class RootUserController : ControllerBase
|
|
{
|
|
readonly IRootUsersService UsersService;
|
|
readonly IGroupUsersService GroupUsersService;
|
|
readonly IPrivateAvatarUsersService AvatarUsersService;
|
|
readonly ILocalActorService LocalActors;
|
|
readonly AuthTokenManager AuthTokenManager;
|
|
readonly IOptionsMonitor<AppConfiguration> AppConfiguration;
|
|
readonly IOptionsMonitor<RegistrationOptions> Registrations;
|
|
readonly ILogger<RootUserController> Logger;
|
|
readonly IStringLocalizer Localizer;
|
|
|
|
public RootUserController(IRootUsersService usersService,
|
|
IGroupUsersService groupUsersService,
|
|
IPrivateAvatarUsersService avatarUsersService,
|
|
ILocalActorService localActors,
|
|
AuthTokenManager authTokenManager,
|
|
IOptionsMonitor<AppConfiguration> appConfiguration,
|
|
IOptionsMonitor<RegistrationOptions> registrations,
|
|
IStringLocalizer<GenericRes> localizer,
|
|
ILogger<RootUserController> logger)
|
|
{
|
|
Registrations = registrations;
|
|
UsersService = usersService;
|
|
GroupUsersService = groupUsersService;
|
|
AvatarUsersService = avatarUsersService;
|
|
LocalActors = localActors;
|
|
AuthTokenManager = authTokenManager;
|
|
AppConfiguration = appConfiguration;
|
|
Localizer = localizer;
|
|
Logger = logger;
|
|
}
|
|
|
|
#region User endpoints
|
|
|
|
[HttpPost, Route("/clientapi/user/signup"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous]
|
|
public async Task<IActionResult> SignUp(LoginForm signUpForm)
|
|
{
|
|
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/");
|
|
var result = new WebResult();
|
|
if (!Registrations.CurrentValue.IsOpen)
|
|
return StatusCode(StatusCodes.Status403Forbidden, result.Invalidate(Localizer[RegistrationOptions.ClosedMessage], StatusCodes.Status403Forbidden));
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
result = await UsersService.SignUpAsync(signUpForm);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
(var user, var userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
|
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
|
|
return Ok(jwtUser);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(SignUp)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/login"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous]
|
|
public async Task<IActionResult> Login(LoginForm loginForm)
|
|
{
|
|
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions");
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
result = await UsersService.LoginAsync(loginForm);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
var (user, userSettings) =
|
|
((RootUser, ViewUserSettings))result.Data;
|
|
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
|
|
|
|
return Ok(jwtUser);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(Login)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/invitation/signup"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
|
public async Task<IActionResult> InvitationSignUp(InvitationLoginForm signUpForm, CancellationToken token)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
var invitation = await GroupUsersService.GetInvitation(signUpForm.InvitationCode, signUpForm.InvitationPassword, token);
|
|
if (!invitation.IsValid)
|
|
return StatusCode(invitation.StatusCode, invitation);
|
|
|
|
var avatarUserName = signUpForm.AvatarUserName?.Trim().ToLowerInvariant();
|
|
var personaProblem = await PersonaProblem(avatarUserName, signUpForm.UserName, token);
|
|
if (personaProblem != default)
|
|
return BadRequest(result.Invalidate(personaProblem));
|
|
|
|
result = await UsersService.SignUpAsync(signUpForm, signUpForm.InvitationCode);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
|
|
|
var avatar = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
|
{
|
|
RootId = user.ID,
|
|
Name = string.IsNullOrWhiteSpace(signUpForm.AvatarName) ? avatarUserName : signUpForm.AvatarName.Trim(),
|
|
UserName = avatarUserName,
|
|
Biography = string.Empty
|
|
});
|
|
if (!avatar.IsValid)
|
|
return StatusCode(avatar.StatusCode, avatar);
|
|
|
|
var joined = await GroupUsersService.JoinGroup(user.ID, new JoinGroupForm
|
|
{
|
|
AvatarId = ((ViewAvatar)avatar.Data).Id,
|
|
InvitationCode = signUpForm.InvitationCode,
|
|
InvitationPassword = signUpForm.InvitationPassword
|
|
}, token);
|
|
if (!joined.IsValid)
|
|
return StatusCode(joined.StatusCode, joined);
|
|
|
|
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationSignUp)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/invitation/login"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
|
public async Task<IActionResult> InvitationLogin(InvitationLoginForm loginForm, CancellationToken token)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
var invitation = await GroupUsersService.GetInvitation(loginForm.InvitationCode, loginForm.InvitationPassword, token);
|
|
if (!invitation.IsValid)
|
|
return StatusCode(invitation.StatusCode, invitation);
|
|
|
|
result = await UsersService.LoginAsync(loginForm, loginForm.InvitationCode);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
|
|
|
var avatars = await AvatarUsersService.GetRootAvatars(user.ID, token);
|
|
var avatarUserName = loginForm.AvatarUserName?.Trim().ToLowerInvariant();
|
|
var avatar = string.IsNullOrEmpty(avatarUserName)
|
|
? (avatars.Data as List<ViewAvatar>)?.FirstOrDefault()
|
|
: (avatars.Data as List<ViewAvatar>)?.FirstOrDefault(a => a.UserName == avatarUserName);
|
|
if (avatar == default)
|
|
{
|
|
var personaProblem = await PersonaProblem(avatarUserName, user.UserName, token);
|
|
if (personaProblem != default)
|
|
return BadRequest(new WebResult().Invalidate(personaProblem));
|
|
var inserted = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
|
{
|
|
RootId = user.ID,
|
|
Name = string.IsNullOrWhiteSpace(loginForm.AvatarName) ? avatarUserName : loginForm.AvatarName.Trim(),
|
|
UserName = avatarUserName,
|
|
Biography = string.Empty
|
|
});
|
|
if (!inserted.IsValid)
|
|
return StatusCode(inserted.StatusCode, inserted);
|
|
avatar = (ViewAvatar)inserted.Data;
|
|
}
|
|
|
|
var joined = await GroupUsersService.JoinGroup(user.ID, new JoinGroupForm
|
|
{
|
|
AvatarId = avatar.Id,
|
|
InvitationCode = loginForm.InvitationCode,
|
|
InvitationPassword = loginForm.InvitationPassword
|
|
}, token);
|
|
if (!joined.IsValid)
|
|
return StatusCode(joined.StatusCode, joined);
|
|
|
|
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationLogin)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
async Task<string> PersonaProblem(string avatarUserName, string rootUserName, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(avatarUserName))
|
|
return Localizer["Choose a username for your persona."];
|
|
if (string.Equals(avatarUserName, rootUserName?.Trim(), StringComparison.OrdinalIgnoreCase))
|
|
return Localizer["Your persona's username must differ from your login."];
|
|
if (await LocalActors.IsUserNameTaken(avatarUserName, token))
|
|
return Localizer["The username '{0}' is already take.", avatarUserName];
|
|
return default;
|
|
}
|
|
|
|
[HttpGet, Route("/clientapi/user/logout"), Authorize(Policy = Policies.IsUser)]
|
|
public IActionResult Logout()
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
return Ok();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(Logout)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/update"), Authorize(Policy = Policies.IsUser)]
|
|
public async Task<IActionResult> UpdateUser(UserForm userEmailForm)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
result = await UsersService.UpdateUserAsync(userEmailForm, User.GetUserId());
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
return Ok();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUser)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/update/settings"), Authorize(Policy = Policies.IsUser)]
|
|
public async Task<IActionResult> UpdateUserSettings(ViewUserSettings userSettings)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
result = await UsersService.UpdateUserSettingsAsync(userSettings, User.GetUserId());
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
return Ok();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUserSettings)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/update/password"), Authorize(Policy = Policies.IsUser)]
|
|
public async Task<IActionResult> UpdatePassword(UserPasswordForm userPasswordForm)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
result = await UsersService.UpdateUserPasswordAsync(userPasswordForm, User.GetUserId());
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
return Ok();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdatePassword)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
//[HttpGet, Authorize(Policy = Policies.IsUser)]
|
|
//public async Task<IActionResult> GetUser()
|
|
//{
|
|
// var result = new WebResult();
|
|
// try
|
|
// {
|
|
// result = await UsersService.GetUserAsync(User.GetUserId());
|
|
// if (!result.IsValid)
|
|
// return StatusCode(result.StatusCode, result);
|
|
|
|
// return Ok(result.Data);
|
|
// }
|
|
// catch (Exception ex)
|
|
// {
|
|
// Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUser)}()");
|
|
// return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
// }
|
|
//}
|
|
|
|
[HttpGet, Route("/clientapi/user/settings"), Authorize(Policy = Policies.IsUser)]
|
|
public async Task<IActionResult> GetUserSettings()
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
result = await UsersService.GetUserSettingsAsync(User.GetUserId());
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
return Ok(result.Data);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUserSettings)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/recover/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
|
public async Task<IActionResult> RecoverPassword(PasswordRecoveryForm passwordRecoveryForm)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
var host = string.IsNullOrEmpty(AppConfiguration.CurrentValue.FrontendBaseAddress)
|
|
? $"{Request.Scheme}://{Request.Host.Host}"
|
|
: AppConfiguration.CurrentValue.FrontendBaseAddress.TrimEnd('/');
|
|
result = await UsersService.SetupAndSendRecoveryEmail(passwordRecoveryForm, host);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
return Ok(result.Data);//the same sentence for everyone
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(RecoverPassword)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/recover/valid"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
|
public async Task<IActionResult> IsValidRecoveryCode(
|
|
[FromBody,
|
|
Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
|
Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
|
|
string recoveryCode)
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
result = await UsersService.IsValidRecoveryCode(recoveryCode);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
return Ok(result.Data);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(IsValidRecoveryCode)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/recover/update/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
|
public async Task<IActionResult> ChangePassword(NewPasswordForm newPasswordForm)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
result = await UsersService.ChangePassword(newPasswordForm);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
return Ok(result.Data);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(ChangePassword)}()");
|
|
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
|
|
|
|
#region Auth refresh
|
|
|
|
[HttpGet, Route("/clientapi/user/sniff/again"), Authorize(Policy = Policies.IsUser)]
|
|
public async Task<IActionResult> SniffAgain([FromServices] DbEntities dbEntities, CancellationToken token)
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
var user = await dbEntities.RootUsers.MatchID(User.GetUserId()).ExecuteFirstAsync(token);
|
|
if (user is not { IsBanned: false, DeletedAt: null })
|
|
return Unauthorized(result.Invalidate(Localizer["Unauthorized."], StatusCodes.Status401Unauthorized));
|
|
result = await UsersService.GetUserSettingsAsync(user.ID);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
return Ok(AuthTokenManager.GenerateToken(user, (ViewUserSettings)result.Data));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(SniffAgain)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|