Files
SocialPub/PrivaPub/Controllers/ClientToServer/RootUserController.cs
T
thepraandClaude Opus 5.5 c5e4934ba6 T5: OAuth and the client API over HTTP
96 integration tests through PrivaPubHost, the real pipeline end to end:
- OAuth: the token's subject is the persona, and neither the token response,
  verify_credentials nor the stored token entries name the root. A wrong password shows
  an error and sets no login cookie; a login without the antiforgery token is a 400; the
  return address never leaves the site; deny answers access_denied with no code; another
  root's persona re-renders the choice with no code; a banned root is sent back to the
  login and a code issued before the ban buys no token; force_login asks again; a code
  works once and its reuse revokes the token it bought; password and refresh_token
  grants are refused; a client_credentials token gets 401 on user routes; a read-only
  token gets 403 with a Mastodon error on POST /api/v1/statuses; follow covers
  read:follows; revoke works; the login and authorize pages send their CSP and no-store;
  the 11th /oauth/login from one address in a minute is a 429.
- Accounts: sign-up, duplicates in any case, invalid models answer 400 with a message,
  login and logout, recovery email, settings, password change, invitation sign-up and
  login (refusing a persona named after the login), recovery without an email, through
  an unreachable mail server, with a wrong and with a valid code, token refresh, the 11th
  sign-up from one address, expired, garbage and foreign-key JWTs.
- Personas: a rootId in the body is ignored, the username regex and reserved names hold,
  personas and groups share ReservedName, an update delivers Update{Person} to followers,
  PublishedOn and the id's day fall within two weeks before creation, the list holds only
  one's own personas, another root's persona cannot be updated.
- Groups: communities and circles are created, joining takes the code and the password,
  members leave and owners cannot, a remote follow request becomes a member only on
  approval, a circle never shows in lookup, account by id, v2 search or /@name, and its
  /flock and /wardens answer 404 unsigned and to non-members, 200 to a member's signed GET.
- Moderation (Exclusive, it suspends localhost): ban, unban and remove; non-admins get
  403; reports are listed without the reporter and resolved; domain blocks are inserted,
  listed and deleted, bad domains refused, and a suspended server's delivery is answered
  202 and kept nowhere; the data endpoints.
- AdminCommands: exit codes 0, 1 and 2 and the resulting policies.

Fixed:
- A banned or removed root kept using /clientapi with its JWT until it expired: only /api
  re-checked the root. JwtEvents.TokenValidated now loads the root and fails the request
  when it is banned or deleted, and takes the policy claims from the database, so a
  demoted admin loses admin at once (and a promoted one gains it).
- The 401 and 403 bodies JwtEvents writes were PascalCase while every other /clientapi
  answer is camelCase; they now use the web defaults.
- /clientapi/user/sniff/again (token refresh) answered an empty 200; it now answers a
  fresh JwtUser, like login.
- Password recovery answered SMTP reply codes as HTTP statuses (421, 454, 554, and 550 for
  an invalid address); a mail server failure is now 503 and an invalid address 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:53:23 +02:00

462 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 ILogger<RootUserController> Logger;
readonly IStringLocalizer Localizer;
public RootUserController(IRootUsersService usersService,
IGroupUsersService groupUsersService,
IPrivateAvatarUsersService avatarUsersService,
ILocalActorService localActors,
AuthTokenManager authTokenManager,
IOptionsMonitor<AppConfiguration> appConfiguration,
IStringLocalizer<GenericRes> localizer,
ILogger<RootUserController> logger)
{
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 (!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);
}
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."]));
}
}
//[HttpDelete, Route("delete"), Authorize(Policy = Policies.IsUser)]
//public async Task<IActionResult> RemoveSelf()
//{
// var result = new WebResult();
// try
// {
// //result = await UsersService.RemoveUserAsync(User.GetUserId());
// if (!result.IsValid)
// return StatusCode(result.StatusCode, result);
// await HttpContext.SignOutAsync();
// return Ok();
// }
// catch (Exception ex)
// {
// Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveSelf)}()");
// return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
// }
//}
#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
}
}