From 5816c29c6789e0dff1e1659f8584261d5cd8d59c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 07:42:06 -0500 Subject: [PATCH] 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 --- overlay/Config/Bridge.cfg | 25 ++ .../Custom/Bridge/BridgeAccountLink.cs | 48 +++ .../Scripts/Custom/Bridge/BridgeAccounts.cs | 281 ++++++++++++++++++ overlay/Scripts/Custom/Bridge/BridgeAdmin.cs | 10 +- overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 79 ++++- 5 files changed, 437 insertions(+), 6 deletions(-) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeAccounts.cs diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index c1beefe..7cff2d6 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -57,6 +57,31 @@ AdminReasonMaxLength=400 # Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite. AdminBanMaxDurationSec=31536000 +# Account provisioning (docs/PROTOCOL_2.md Part A). Which side may mint game accounts: +# website — the website is the authority; pair with Accounts.AutoCreateAccounts=false +# (else an in-game login of any new name still mints an account). +# game — the game server is the authority; website account.create is refused. +# hybrid — either side may create (the default). +# The bridge governs only the account.create verb; the in-game first-login auto-create is +# the core Accounts.AutoCreateAccounts setting, which you pair with the mode above. On boot +# the bridge warns if the two contradict. An unrecognized value here falls back to 'game' +# (the safest — no website creation). +SignupMode=hybrid + +# Master switch for the account.create verb. Absent, it follows the mode (on unless +# SignupMode=game). Set explicitly to force it on or off regardless of mode. +AccountCreateEnabled=true + +# Fail closed if account.create omits a usable browser IP. The per-IP cap +# (Accounts.AccountsPerIp) only means something if a missing/loopback IP is refused rather +# than waved through. Turn off only for a deployment that deliberately does not cap website +# signups by IP (MaxAccountsPerIP still applies in-game either way). +RequireIpForCreate=true + +# Length caps on a website-supplied username / password, checked before the account is made. +AccountNameMaxLength=16 +AccountPasswordMaxLength=30 + # The test scaffolding in tools/scaffolding/ reads its own flags from this file # (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose: # Config.Get returns the default of false when a key is missing, so a deployed diff --git a/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs b/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs index d9a34e2..5e0b1ef 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs @@ -52,6 +52,7 @@ namespace Server.Custom.Bridge return; CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand); + CommandSystem.Register("unlink", AccessLevel.Player, OnUnlinkCommand); BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm); // Purge expired codes so an unconfirmed spam of [link cannot grow the table forever. @@ -127,6 +128,53 @@ namespace Server.Custom.Bridge url, (int)CodeTtl.TotalMinutes); } + // ---- [unlink ---- + + [Usage("unlink")] + [Description("Unlinks this game account from your website account.")] + private static void OnUnlinkCommand(CommandEventArgs e) + { + Unlink(e.Mobile); + } + + /// + /// Clears the WebsiteUserId tie from the caller's own account and tells the sidecar, so + /// the website can reconcile a player-initiated unlink. Player-scoped (own account only), + /// so it needs no access floor. After unlinking, [link works again. + /// + public static void Unlink(Mobile m) + { + if (m == null) + return; + + var acct = m.Account as Account; + + if (acct == null) + { + m.SendMessage("Bridge: no account on this character."); + return; + } + + var existing = acct.GetTag(Tag); + if (existing == null) + { + m.SendMessage("Your account is not linked to a website account."); + return; + } + + acct.RemoveTag(Tag); + DropCodesFor(acct.Username); // drop any pending codes so nothing dangles + + BridgeLink.Emit(BridgeJson.Begin("account.unlinked") + .Str("origin", "in-game") + .Str("account", acct.Username) + .Str("websiteUserId", existing) + .Str("char", m.Name) + .End()); + + m.SendMessage(0x40, "Your account is no longer linked to website user {0}.", existing); + } + // ---- inbound link.confirm ---- private static void OnLinkConfirm(Dictionary o) diff --git a/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs b/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs new file mode 100644 index 0000000..e076eab --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.Net; + +using Server.Accounting; +using Server.Misc; + +namespace Server.Custom.Bridge +{ + /// + /// 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. + /// + 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 ---- + + /// + /// 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. + /// + private static void OnCreate(Dictionary 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 ---- + + /// + /// 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. + /// + private static void OnUnlink(Dictionary 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()); + } + + /// + /// Opens an account.audit frame (origin=web) broadcast to every dashboard, parallel to + /// admin.audit. Never carries the password. + /// + 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); + } + + /// Mirrors the username safety rules in AccountHandler.CreateAccount. + 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; + } + + /// Mirrors the password safety rules in AccountHandler.CreateAccount. + 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); + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs b/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs index 1171713..b82d893 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs @@ -283,9 +283,10 @@ namespace Server.Custom.Bridge /// /// Resolves the command's target account, by "serial" (a player mobile's account) or by - /// "account" (username). Returns null if neither resolves to a real account. + /// "account" (username). Returns null if neither resolves to a real account. Public so the + /// account plane (unlink) resolves targets the same way the moderation plane does. /// - private static Account ResolveTargetAccount(Dictionary o) + public static Account ResolveTargetAccount(Dictionary o) { var serialStr = BridgeJson.GetString(o, "serial"); if (serialStr != null) @@ -301,9 +302,10 @@ namespace Server.Custom.Bridge /// /// The one shard-side safety floor. Protects any account whose effective access level — /// the account's own or the highest of its characters' — is at or above the configured - /// floor. Even under CoOwner authority the Owner is never reachable from the web. + /// floor. Even under CoOwner authority the Owner is never reachable from the web. Public + /// so the account plane (unlink) enforces the identical floor. /// - private static bool Protected(Account acct) + public static bool Protected(Account acct) { var lvl = acct.AccessLevel; diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index 627e5e4..5ab17f2 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -2,6 +2,18 @@ using System; namespace Server.Custom.Bridge { + /// + /// Which side may mint game accounts. Governs the bridge's inbound account.create verb; + /// the in-game first-login auto-create is a separate core setting (Accounts.AutoCreateAccounts) + /// the operator pairs with this (docs/PROTOCOL_2.md §2). + /// + public enum SignupMode + { + Website, // website is the account authority; in-game auto-create should be off + Game, // game server is the authority; account.create is refused + Hybrid // either side may create + } + /// /// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there /// reads as "Bridge.Port" here. @@ -33,6 +45,13 @@ namespace Server.Custom.Bridge public static int AdminReasonMaxLength { get; private set; } public static int AdminBanMaxDurationSec { get; private set; } + // ---- account provisioning (docs/PROTOCOL_2.md Part A) ---- + public static SignupMode Signup { get; private set; } + public static bool AccountCreateEnabled { get; private set; } + public static bool RequireIpForCreate { get; private set; } + public static int AccountNameMaxLength { get; private set; } + public static int AccountPasswordMaxLength { get; private set; } + public static bool Enabled { get; private set; } public static void Configure() @@ -73,8 +92,64 @@ namespace Server.Custom.Bridge AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400); AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000); + // Account provisioning. An absent SignupMode defaults to Hybrid; a *present but + // unrecognized* value falls back to Game (the safest — no website creation), so a + // typo can never accidentally open provisioning. + Signup = ParseSignupMode(Config.Get("Bridge.SignupMode", "hybrid"), SignupMode.Game); + // Default follows the mode: creation is on unless the shard is game-authority. + AccountCreateEnabled = Config.Get("Bridge.AccountCreateEnabled", Signup != SignupMode.Game); + RequireIpForCreate = Config.Get("Bridge.RequireIpForCreate", true); + AccountNameMaxLength = Config.Get("Bridge.AccountNameMaxLength", 16); + AccountPasswordMaxLength = Config.Get("Bridge.AccountPasswordMaxLength", 30); + if (AccountNameMaxLength < 1) + AccountNameMaxLength = 1; + if (AccountPasswordMaxLength < 1) + AccountPasswordMaxLength = 1; + if (QueueCap < 16) QueueCap = 16; + + WarnOnSignupMismatch(); + } + + /// + /// The bridge governs only the account.create verb; ServUO's in-game first-login + /// auto-create is the core Accounts.AutoCreateAccounts setting. A shard whose two halves + /// disagree is quietly broken (website-only that still auto-creates in game, or a mode + /// that expects in-game creation with it switched off), so surface the contradiction + /// loudly rather than silently doing the permissive thing. + /// + private static void WarnOnSignupMismatch() + { + var autoCreate = Config.Get("Accounts.AutoCreateAccounts", true); + + if (Signup == SignupMode.Website && autoCreate) + Console.WriteLine( + "[Bridge] WARNING: SignupMode=website but Accounts.AutoCreateAccounts=true; " + + "an in-game login of any new name still mints an account. Set it false for website-only."); + else if (Signup == SignupMode.Game && !autoCreate) + Console.WriteLine( + "[Bridge] WARNING: SignupMode=game but Accounts.AutoCreateAccounts=false; " + + "in-game creation is off and account.create is refused, so no account can be created."); + else if (Signup == SignupMode.Hybrid && !autoCreate) + Console.WriteLine( + "[Bridge] WARNING: SignupMode=hybrid but Accounts.AutoCreateAccounts=false; " + + "in-game first-login creation is off. Only website account.create will work."); + } + + /// + /// Parses a SignupMode name, case-insensitively, falling back to + /// on anything unrecognized so a typo can never open provisioning wider than intended. + /// + private static SignupMode ParseSignupMode(string value, SignupMode fallback) + { + SignupMode parsed; + if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) && + Enum.IsDefined(typeof(SignupMode), parsed)) + return parsed; + + Console.WriteLine("[Bridge] unrecognized SignupMode '{0}', using {1}", value, fallback); + return fallback; } /// @@ -95,9 +170,9 @@ namespace Server.Custom.Bridge public static string Describe() { return String.Format( - "enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9})", + "enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11})", Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds, - ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor); + ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled); } } }