First-time setup: the first login made while no administrator exists becomes it
Sign-up stays closed (invitations only), so a new server's first login could only be made over SSH with admin create-root, and an invitation needs a group, which needs a login. While no administrator exists, GET /clientapi/user/setup says so and POST takes a SetupForm (login, password, first persona): the login is made the administrator with its persona, and setup closes for good (owner decision 2026-10-16: first come, first served, no setup code). ServerSetup is one document with a fixed id, so the first request to insert it wins and two setups at once make one administrator. A setup that fails before its login is made gives the claim back; one that named an administrator never does, whoever is demoted or deleted later, and the CLI stays the way back. admin promote and create-root --admin seal it too, migration _018 seals a server that already had an administrator, and no backup holds it nor does a restore drop it, since a restore tombstones an administrator made after the backup. Public sign-up, NodeInfo and the instance API are unchanged. RootUser.SetAdmin is the one place an administrator is made (with isModerator, which IsAdminPolicy asks for), and the invitation endpoints share InsertPersona with setup. Tests: ServerSetupTests on the invitations-only host (setup, the race, failed setups, sign-up still closed, the CLI's seal) and the migration; the whole suite passes (962). In the pasture, decePub's setup and invitation pages pass end to end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
088c54ddf3
commit
8156c1927c
12 files changed
+396
-27
No files matched your search
@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
using PrivaPub.ClientModels.Group;
|
||||
@@ -135,13 +137,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
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
|
||||
});
|
||||
var avatar = await InsertPersona(user.ID, avatarUserName, signUpForm.AvatarName);
|
||||
if (!avatar.IsValid)
|
||||
return StatusCode(avatar.StatusCode, avatar);
|
||||
|
||||
@@ -190,13 +186,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
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
|
||||
});
|
||||
var inserted = await InsertPersona(user.ID, avatarUserName, loginForm.AvatarName);
|
||||
if (!inserted.IsValid)
|
||||
return StatusCode(inserted.StatusCode, inserted);
|
||||
avatar = (ViewAvatar)inserted.Data;
|
||||
@@ -220,6 +210,77 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet, Route("/clientapi/user/setup"), AllowAnonymous]
|
||||
public async Task<IActionResult> Setup(CancellationToken token) =>
|
||||
Ok(new ViewServerSetup { IsPending = await ServerSetup.IsPending(token) });
|
||||
|
||||
// The server's first login, made while it has no administrator (owner decision 2026-10-16: first come, first served):
|
||||
// it is made the administrator, with its first persona, and setup closes for good. Sign-up stays closed to everyone
|
||||
// else (Registrations), so NodeInfo and the instance API don't change.
|
||||
[HttpPost, Route("/clientapi/user/setup"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
||||
public async Task<IActionResult> SetUp(SetupForm setupForm, CancellationToken token)
|
||||
{
|
||||
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/");
|
||||
var result = new WebResult();
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||
var claimed = false;
|
||||
var adminId = default(string);
|
||||
try
|
||||
{
|
||||
if (!await ServerSetup.IsPending(token))
|
||||
return SetUpAlready();
|
||||
|
||||
var avatarUserName = setupForm.AvatarUserName?.Trim().ToLowerInvariant();
|
||||
var personaProblem = await PersonaProblem(avatarUserName, setupForm.UserName, token);
|
||||
if (personaProblem != default)
|
||||
return BadRequest(result.Invalidate(personaProblem));
|
||||
|
||||
claimed = await ServerSetup.Claim(token);
|
||||
if (!claimed)
|
||||
return SetUpAlready();
|
||||
|
||||
result = await UsersService.SignUpAsync(setupForm);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
await ServerSetup.Release();
|
||||
return StatusCode(result.StatusCode, result);
|
||||
}
|
||||
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
||||
user.SetAdmin(true);
|
||||
await DB.Default.SaveAsync(user, CancellationToken.None);
|
||||
adminId = user.ID;
|
||||
await ServerSetup.Claimed(adminId, CancellationToken.None);
|
||||
|
||||
// set up from here, whatever happens to the persona: it can be made in the client's settings
|
||||
var avatar = await InsertPersona(user.ID, avatarUserName, setupForm.AvatarName);
|
||||
if (!avatar.IsValid)
|
||||
return StatusCode(avatar.StatusCode, avatar);
|
||||
|
||||
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(SetUp)}()");
|
||||
if (claimed && adminId == default)
|
||||
await ServerSetup.Release();
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
IActionResult SetUpAlready() =>
|
||||
StatusCode(StatusCodes.Status403Forbidden,
|
||||
new WebResult().Invalidate(Localizer["This server is already set up: joining it takes a group invitation."], StatusCodes.Status403Forbidden));
|
||||
|
||||
Task<WebResult> InsertPersona(string rootId, string avatarUserName, string avatarName) =>
|
||||
AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
||||
{
|
||||
RootId = rootId,
|
||||
Name = string.IsNullOrWhiteSpace(avatarName) ? avatarUserName : avatarName.Trim(),
|
||||
UserName = avatarUserName,
|
||||
Biography = string.Empty
|
||||
});
|
||||
|
||||
async Task<string> PersonaProblem(string avatarUserName, string rootUserName, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(avatarUserName))
|
||||
|
||||
@@ -40,7 +40,8 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
[nameof(MaintenanceLock)] = "who is backing up or restoring now",
|
||||
["AppConfiguration"] = "the configuration's copy holds the SMTP password, and is made again from appsettings at boot",
|
||||
[nameof(RestoreRecord)] = "what restores did outlives what they restore",
|
||||
["PersonaArchive"] = "personas' archives being made or imported belong to the moment"
|
||||
["PersonaArchive"] = "personas' archives being made or imported belong to the moment",
|
||||
["ServerSetup"] = "the server is set up once: a restore never opens its setup again"
|
||||
};
|
||||
|
||||
static readonly JsonWriterSettings Json = new() { OutputMode = JsonOutputMode.CanonicalExtendedJson, Indent = false };
|
||||
|
||||
@@ -4,6 +4,7 @@ using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.User;
|
||||
using PrivaPub.ClientModels.User.Avatar;
|
||||
using PrivaPub.Infrastructure.Backup;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.Services.ClientToServer.Private;
|
||||
@@ -194,19 +195,17 @@ namespace PrivaPub.Infrastructure.Cli
|
||||
return 1;
|
||||
}
|
||||
|
||||
user.Policies.RemoveAll(p => p is Policies.IsAdmin or Policies.IsModerator);
|
||||
if (promote)
|
||||
user.Policies.AddRange(new[] { Policies.IsAdmin, Policies.IsModerator });
|
||||
if (!user.Policies.Contains(Policies.IsUser))
|
||||
user.Policies.Add(Policies.IsUser);
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
user.SetAdmin(promote);
|
||||
await DB.Default.SaveAsync(user);
|
||||
if (promote)
|
||||
await ServerSetup.Seal(user.ID, default);//an administrator exists: the client's setup is closed
|
||||
|
||||
output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// With sign-up closed this is how the first root is made; group invitations make the rest.
|
||||
// With sign-up closed, this or the client's setup (while no administrator exists) makes the first root; group invitations
|
||||
// make the rest.
|
||||
static async Task<int> CreateRoot(IServiceProvider services, string login, string password, bool admin, TextWriter output)
|
||||
{
|
||||
var form = new LoginForm { UserName = login, Password = password?.Trim() };
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Data.Migrations
|
||||
{
|
||||
// the client's setup is open only while no administrator exists (ServerSetup): a server that already has one, made with
|
||||
// the CLI before setup existed, is set up
|
||||
public class _018_a_server_with_an_administrator_is_set_up : IMigration
|
||||
{
|
||||
public async Task UpgradeAsync()
|
||||
{
|
||||
var admin = await DB.Default.Find<RootUser>()
|
||||
.Match(u => u.Policies.Contains(Policies.IsAdmin))
|
||||
.Sort(u => u.CreatedAt, Order.Ascending)
|
||||
.ExecuteFirstAsync();
|
||||
if (admin != default)
|
||||
await ServerSetup.Seal(admin.ID, default);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models
|
||||
{
|
||||
// The server's first-time setup (owner decision 2026-10-16): while no administrator exists, the client's setup page makes
|
||||
// the first login, which becomes the administrator; this document then closes setup for good, whoever is demoted or
|
||||
// deleted later (the CLI stays the way back). The first request to insert it wins, so two setups at once make one
|
||||
// administrator. A backup never holds it and a restore never drops it (ServerBackup.Excluded): a restore tombstones an
|
||||
// administrator made after the backup, and setup would otherwise be open to whoever came first.
|
||||
public class ServerSetup : Entity
|
||||
{
|
||||
public const string Name = "setup";
|
||||
|
||||
public string AdminId { get; set; }//null while the claiming request is still making its login
|
||||
public DateTime SealedAt { get; set; }
|
||||
|
||||
public static async Task<bool> IsPending(CancellationToken token) =>
|
||||
!await DB.Default.Find<ServerSetup>().Match(s => s.ID == Name).ExecuteAnyAsync(token);
|
||||
|
||||
/// <summary>Takes the setup for one request: false when it is done, or another request took it first.</summary>
|
||||
public static async Task<bool> Claim(CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DB.Default.InsertAsync(new ServerSetup { ID = Name, SealedAt = DateTime.UtcNow }, token);
|
||||
return true;
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Names the administrator the claiming request made.</summary>
|
||||
public static Task Claimed(string adminId, CancellationToken token) =>
|
||||
DB.Default.Update<ServerSetup>().Match(s => s.ID == Name && s.AdminId == null).Modify(s => s.AdminId, adminId).ExecuteAsync(token);
|
||||
|
||||
/// <summary>Gives a claim back when its login couldn't be made; a named administrator is never given back.</summary>
|
||||
public static Task Release() =>
|
||||
DB.Default.DeleteAsync<ServerSetup>(s => s.ID == Name && s.AdminId == null);//a delete takes no token outside a transaction
|
||||
|
||||
/// <summary>Closes setup for an administrator made elsewhere (the CLI, a server that had one already).</summary>
|
||||
public static async Task Seal(string adminId, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DB.Default.Update<ServerSetup>().Match(s => s.ID == Name)
|
||||
.Modify(b => b.SetOnInsert(s => s.AdminId, adminId))
|
||||
.Modify(b => b.SetOnInsert(s => s.SealedAt, DateTime.UtcNow))
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
}
|
||||
catch (MongoCommandException ex) when (ex.Code == 11000)
|
||||
{
|
||||
//sealed meanwhile
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
//sealed meanwhile
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,17 @@ namespace PrivaPub.Models.User
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
public DateTime? CredentialsChangedAt { get; set; }//when RootSessions last ended every session
|
||||
public string SessionStamp { get; set; }//carried by every /clientapi token; a new one ends the tokens issued before
|
||||
|
||||
/// <summary>An administrator is a moderator too (IsAdminPolicy asks for both); otherwise a plain user.</summary>
|
||||
public void SetAdmin(bool admin)
|
||||
{
|
||||
Policies.RemoveAll(p => p is ClientModels.Policies.IsAdmin or ClientModels.Policies.IsModerator);
|
||||
if (admin)
|
||||
Policies.AddRange([ClientModels.Policies.IsAdmin, ClientModels.Policies.IsModerator]);
|
||||
if (!Policies.Contains(ClientModels.Policies.IsUser))
|
||||
Policies.Add(ClientModels.Policies.IsUser);
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
public class ContactItem
|
||||
|
||||
Reference in new issue
Block a user