feat(protocol2): website account provisioning & unlinking (Part A)
Adds the account-provisioning plane from docs/PROTOCOL_2.md Part A: the website can create game accounts and unlink them, gated by a shard-wide signup mode. The existing [link flow is unchanged. Overlay: - BridgeConfig: SignupMode (website|game|hybrid, default hybrid; unrecognized falls back to game), AccountCreateEnabled (mode-following default), RequireIpForCreate, name/password caps, and a boot warning when the core Accounts.AutoCreateAccounts setting contradicts the mode. - BridgeAccounts (new): account.create (mode gate, actor required, char-safety mirrored from AccountHandler, collision check, per-IP cap via CanCreate/ LogAccess with fail-closed missing/loopback IP, create + WebsiteUserId link, account.audit; password never logged or echoed) and account.unlink (Owner floor via BridgeAdmin.Protected, clears the tag). - BridgeAccountLink: in-game [unlink command, emits account.unlinked. - BridgeAdmin: Protected / ResolveTargetAccount promoted to public for reuse. Sidecar: - POST /accounts/create, DELETE /link/:account, respond_account status mapping (409 collision / 429 ip cap / 403 disabled|protected / 404 not-linked / 400). - store.record_unlink drops the mirrored link row. - PROTOCOL_VERSION -> 2 (outbound events additive; new endpoints need v2). Docs: INTEGRATION.md protocol bump, account.* events, endpoints, 409/429; PROTOCOL_2.md Part A marked built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
281
overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
Normal file
281
overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
Normal file
@@ -0,0 +1,281 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Misc;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The account provisioning plane (docs/PROTOCOL_2.md Part A): website-driven account
|
||||
/// creation and unlinking. Companion to BridgeAccountLink (the in-game [link flow), which
|
||||
/// is unchanged.
|
||||
///
|
||||
/// account.create — mint a game account and link it to a website user in one step.
|
||||
/// account.unlink — sever the WebsiteUserId tie from the website side.
|
||||
///
|
||||
/// Both handlers run on the Core thread (BridgeBoot marshals inbound lines through
|
||||
/// Timer.DelayCall first), so they touch accounts freely.
|
||||
///
|
||||
/// Trust model matches the admin plane (docs/ADMIN_CONTROLS.md §5): authorization lives on
|
||||
/// the website; the shard trusts the loopback + token socket and a required "actor" field.
|
||||
/// The one shard-side floor on unlink is BridgeAdmin.Protected — a protected staff account is
|
||||
/// never unlinkable from the web. The whole create plane is opt-in via SignupMode /
|
||||
/// AccountCreateEnabled.
|
||||
/// </summary>
|
||||
public static class BridgeAccounts
|
||||
{
|
||||
private const string Tag = "WebsiteUserId";
|
||||
|
||||
// Mirrors AccountHandler.m_ForbiddenChars so a website-created name behaves exactly like an
|
||||
// in-game one (AccountHandler.cs). Kept local because that array is private.
|
||||
private static readonly char[] ForbiddenChars =
|
||||
{
|
||||
'<', '>', ':', '"', '/', '\\', '|', '?', '*', ' '
|
||||
};
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeBoot.RegisterHandler("account.create", OnCreate);
|
||||
BridgeBoot.RegisterHandler("account.unlink", OnUnlink);
|
||||
}
|
||||
|
||||
// ---- account.create ----
|
||||
|
||||
/// <summary>
|
||||
/// Creates a game account and links it to the given website user. Refused unless the
|
||||
/// signup mode allows website creation. Enforces the same username/password character
|
||||
/// safety and per-IP cap as ServUO's in-game create path; the password never leaves the
|
||||
/// process in any reply, audit, or log.
|
||||
/// </summary>
|
||||
private static void OnCreate(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
var actor = BridgeJson.GetString(o, "actor");
|
||||
const string action = "create";
|
||||
|
||||
if (!BridgeConfig.AccountCreateEnabled || BridgeConfig.Signup == SignupMode.Game)
|
||||
{
|
||||
Err(reqId, action, "signups disabled for this mode");
|
||||
return;
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
|
||||
{
|
||||
Err(reqId, action, "missing actor");
|
||||
return;
|
||||
}
|
||||
|
||||
var account = BridgeJson.GetString(o, "account");
|
||||
var password = BridgeJson.GetString(o, "password");
|
||||
var webId = BridgeJson.GetString(o, "websiteUserId");
|
||||
var ipStr = BridgeJson.GetString(o, "ip");
|
||||
|
||||
if (String.IsNullOrEmpty(account))
|
||||
{
|
||||
Err(reqId, action, "missing account");
|
||||
return;
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(password))
|
||||
{
|
||||
Err(reqId, action, "missing password");
|
||||
return;
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(webId))
|
||||
{
|
||||
Err(reqId, action, "missing websiteUserId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (account.Length > BridgeConfig.AccountNameMaxLength ||
|
||||
password.Length > BridgeConfig.AccountPasswordMaxLength)
|
||||
{
|
||||
Err(reqId, action, "username or password too long");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsSafeUsername(account) || !IsSafePassword(password))
|
||||
{
|
||||
Err(reqId, action, "invalid username/password");
|
||||
return;
|
||||
}
|
||||
|
||||
// Collision: the only correct resolution of a website/in-game race for a name.
|
||||
if (Accounts.GetAccount(account) != null)
|
||||
{
|
||||
Err(reqId, action, "account already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-IP cap. Fail closed on a missing/loopback IP when RequireIpForCreate — loopback is
|
||||
// exempt in IPLimiter, so accepting it would silently bypass the cap.
|
||||
IPAddress ip;
|
||||
bool haveIp = TryParseIp(ipStr, out ip);
|
||||
|
||||
if (BridgeConfig.RequireIpForCreate && (!haveIp || IPAddress.IsLoopback(ip)))
|
||||
{
|
||||
Err(reqId, action, "client ip required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (haveIp && !AccountHandler.CanCreate(ip))
|
||||
{
|
||||
Err(reqId, action, "ip account limit reached");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create + link. new Account self-registers (Accounts.Add) and hashes the password per
|
||||
// the shard's ProtectPasswords; LogAccess records the IP and bumps IPTable exactly as an
|
||||
// in-game first-login does; the tag persists on the next world save.
|
||||
var acct = new Account(account, password);
|
||||
|
||||
if (haveIp)
|
||||
acct.LogAccess(ip);
|
||||
|
||||
acct.SetTag(Tag, webId);
|
||||
|
||||
Console.WriteLine("[Bridge][account] web:{0} create {1} websiteUserId={2} ip={3}",
|
||||
actor, account, webId, haveIp ? ip.ToString() : "-");
|
||||
|
||||
BridgeLink.Emit(AuditBegin(action, actor, account)
|
||||
.Str("websiteUserId", webId)
|
||||
.End());
|
||||
|
||||
var sb = BridgeJson.Begin("account.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", action).Str("account", account).Str("websiteUserId", webId);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- account.unlink ----
|
||||
|
||||
/// <summary>
|
||||
/// Removes the WebsiteUserId tie from an account. Symmetric with the in-game [unlink; the
|
||||
/// Owner floor keeps a protected staff account unreachable from the web.
|
||||
/// </summary>
|
||||
private static void OnUnlink(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
var actor = BridgeJson.GetString(o, "actor");
|
||||
const string action = "unlink";
|
||||
|
||||
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
|
||||
{
|
||||
Err(reqId, action, "missing actor");
|
||||
return;
|
||||
}
|
||||
|
||||
var acct = BridgeAdmin.ResolveTargetAccount(o);
|
||||
if (acct == null)
|
||||
{
|
||||
Err(reqId, action, "unknown or accountless target");
|
||||
return;
|
||||
}
|
||||
|
||||
if (BridgeAdmin.Protected(acct))
|
||||
{
|
||||
Err(reqId, action, "target is protected staff; refused");
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = acct.GetTag(Tag);
|
||||
if (existing == null)
|
||||
{
|
||||
Err(reqId, action, "not linked");
|
||||
return;
|
||||
}
|
||||
|
||||
acct.RemoveTag(Tag);
|
||||
|
||||
Console.WriteLine("[Bridge][account] web:{0} unlink {1} (was websiteUserId={2})",
|
||||
actor, acct.Username, existing);
|
||||
|
||||
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
|
||||
.Str("websiteUserId", existing)
|
||||
.End());
|
||||
|
||||
var sb = BridgeJson.Begin("account.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", action).Str("account", acct.Username);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static void Err(string reqId, string action, string reason)
|
||||
{
|
||||
var sb = BridgeJson.Begin("account.error");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
if (action != null) sb.Str("action", action);
|
||||
sb.Str("reason", reason);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens an account.audit frame (origin=web) broadcast to every dashboard, parallel to
|
||||
/// admin.audit. Never carries the password.
|
||||
/// </summary>
|
||||
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
|
||||
{
|
||||
return BridgeJson.Begin("account.audit")
|
||||
.Str("origin", "web")
|
||||
.Str("action", action)
|
||||
.Str("actor", "web:" + actor)
|
||||
.Str("target", target);
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the username safety rules in AccountHandler.CreateAccount.</summary>
|
||||
private static bool IsSafeUsername(string un)
|
||||
{
|
||||
if (un.StartsWith(" ") || un.EndsWith(" ") || un.EndsWith("."))
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < un.Length; i++)
|
||||
{
|
||||
char c = un[i];
|
||||
if (c < 0x20 || c >= 0x7F || IsForbidden(c))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the password safety rules in AccountHandler.CreateAccount.</summary>
|
||||
private static bool IsSafePassword(string pw)
|
||||
{
|
||||
for (int i = 0; i < pw.Length; i++)
|
||||
{
|
||||
char c = pw[i];
|
||||
if (c < 0x20 || c >= 0x7F)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsForbidden(char c)
|
||||
{
|
||||
for (int i = 0; i < ForbiddenChars.Length; i++)
|
||||
if (c == ForbiddenChars[i])
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseIp(string s, out IPAddress ip)
|
||||
{
|
||||
ip = null;
|
||||
|
||||
if (String.IsNullOrEmpty(s))
|
||||
return false;
|
||||
|
||||
return IPAddress.TryParse(s.Trim(), out ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user