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>
282 lines
9.9 KiB
C#
282 lines
9.9 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|