Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs
Claude 5816c29c67 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>
2026-07-17 07:42:06 -05:00

299 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using Server.Accounting;
using Server.Commands;
namespace Server.Custom.Bridge
{
/// <summary>
/// Ties a game account to a website account.
///
/// Flow:
/// 1. In game, the player runs [link. The shard mints a short, one-time, expiring code,
/// holds it in memory keyed to their account, and emits link.request to the sidecar.
/// 2. The player enters that code on the website. The website tells the sidecar, which
/// sends link.confirm inbound.
/// 3. The shard validates the code, writes Account tag "WebsiteUserId", drops the code,
/// and replies link.ok. The tag persists to accounts.xml across restarts.
///
/// The code table and the account write both live on the Core thread. The websiteUserId in
/// link.confirm is trusted only because the socket is loopback-only (docs/PLAN.md §2); if the
/// sidecar ever moves off-host, gate it behind a shared secret.
/// </summary>
public static class BridgeAccountLink
{
private const string Tag = "WebsiteUserId";
// Unambiguous alphabet: no O/0, I/1, so a player reading a code aloud can't get it wrong.
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private const int CodeLength = 6;
private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5);
private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30);
private sealed class Pending
{
public string Account;
public DateTime Expires;
}
// code -> pending link. Core-thread only.
private static readonly Dictionary<string, Pending> _codes =
new Dictionary<string, Pending>(StringComparer.OrdinalIgnoreCase);
// account -> last [link time, to rate-limit code spam.
private static readonly Dictionary<string, DateTime> _lastRequest =
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
public static void Initialize()
{
if (!BridgeConfig.Enabled)
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.
Timer.DelayCall(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0), PurgeExpired);
}
/// <summary>Reads the linked website id for an account, or null. Used to enrich events.</summary>
public static string WebIdFor(Account acct)
{
if (acct == null)
return null;
return acct.GetTag(Tag);
}
// ---- [link ----
[Usage("link")]
[Description("Links this game account to your website account via a one-time code.")]
private static void OnLinkCommand(CommandEventArgs e)
{
RequestLink(e.Mobile);
}
/// <summary>
/// Mints a one-time code for the mobile's account and emits link.request. This is the
/// body of the [link command, exposed so it can be driven in tests without a client.
/// </summary>
public static void RequestLink(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 already linked to website user {0}.", existing);
return;
}
DateTime last;
if (_lastRequest.TryGetValue(acct.Username, out last) && DateTime.UtcNow - last < RequestCooldown)
{
m.SendMessage("Please wait a moment before requesting another link code.");
return;
}
// One outstanding code per account: drop any prior code so only the newest works.
DropCodesFor(acct.Username);
var code = MintCode();
_codes[code] = new Pending { Account = acct.Username, Expires = DateTime.UtcNow + CodeTtl };
_lastRequest[acct.Username] = DateTime.UtcNow;
BridgeLink.Emit(BridgeJson.Begin("link.request")
.Str("code", code)
.Str("account", acct.Username)
.Str("char", m.Name)
.Num("ttlSec", (long)CodeTtl.TotalSeconds)
.End());
var url = BridgeConfig.LinkUrl;
m.SendMessage(0x35, "Link code: {0}", code);
m.SendMessage("Enter it at {0} within {1} minutes to link your account.",
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);
}
/// <summary>
/// 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.
/// </summary>
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<string, object> o)
{
var code = BridgeJson.GetString(o, "code");
var webId = BridgeJson.GetString(o, "websiteUserId");
if (code == null || webId == null)
{
Reply("link.error", null, null, "malformed link.confirm");
return;
}
Pending pending;
if (!_codes.TryGetValue(code, out pending))
{
Reply("link.error", code, null, "unknown or expired code");
return;
}
_codes.Remove(code);
if (DateTime.UtcNow > pending.Expires)
{
Reply("link.error", code, pending.Account, "code expired");
return;
}
var acct = Accounting.Accounts.GetAccount(pending.Account) as Account;
if (acct == null)
{
Reply("link.error", code, pending.Account, "account no longer exists");
return;
}
// Persisted to accounts.xml on the next world save.
acct.SetTag(Tag, webId);
DropCodesFor(pending.Account);
Reply("link.ok", code, pending.Account, null, webId);
NotifyOnline(acct, webId);
}
// ---- helpers ----
private static void Reply(string kind, string code, string account, string reason, string webId = null)
{
var sb = BridgeJson.Begin(kind);
if (code != null) sb.Str("code", code);
if (account != null) sb.Str("account", account);
if (webId != null) sb.Str("websiteUserId", webId);
if (reason != null) sb.Str("reason", reason);
BridgeLink.Emit(sb.End());
}
private static void NotifyOnline(Account acct, string webId)
{
for (int i = 0; i < acct.Length; i++)
{
var m = acct[i];
if (m != null && m.NetState != null)
m.SendMessage(0x40, "Your account is now linked to website user {0}.", webId);
}
}
private static string MintCode()
{
// Avoid a collision with an outstanding code, though at 32^6 it is astronomically rare.
for (int attempt = 0; attempt < 8; attempt++)
{
var chars = new char[CodeLength];
for (int i = 0; i < CodeLength; i++)
chars[i] = Alphabet[Utility.Random(Alphabet.Length)];
var code = new string(chars);
if (!_codes.ContainsKey(code))
return code;
}
// Fall back to a guaranteed-unique code.
return "L" + DateTime.UtcNow.Ticks.ToString("X").Substring(0, CodeLength - 1);
}
private static void DropCodesFor(string account)
{
var doomed = new List<string>();
foreach (var kv in _codes)
{
if (String.Equals(kv.Value.Account, account, StringComparison.OrdinalIgnoreCase))
doomed.Add(kv.Key);
}
foreach (var c in doomed)
_codes.Remove(c);
}
private static void PurgeExpired()
{
try
{
var now = DateTime.UtcNow;
var doomed = new List<string>();
foreach (var kv in _codes)
{
if (now > kv.Value.Expires)
doomed.Add(kv.Key);
}
foreach (var c in doomed)
_codes.Remove(c);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] link purge threw: {0}", ex.Message);
}
}
}
}