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); } } }