Replaces release-on-reissue. A connection that already holds an id gets that same id back, however many times it re-selects, so a second one is never minted and there is no orphan to clean up afterwards. This leaves the login state machine alone, which is the point: choosing a server queues a disconnect that is never cancelled, but the queue drains on the next slice, so a pipelined seed/login/select still reaches the issuing path. Making that path idempotent is cheaper and safer than teaching the protocol handler to ignore what follows a server select. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
340 lines
11 KiB
C#
340 lines
11 KiB
C#
using System;
|
|
using System.Net;
|
|
using Server.Accounting;
|
|
using Server.Accounting.Security;
|
|
using Server.Network;
|
|
using Server.Tests.Network;
|
|
using Xunit;
|
|
|
|
namespace Server.Tests.Network.Packets;
|
|
|
|
[Collection("Sequential UOContent Tests")]
|
|
public class AuthIdTests : IDisposable
|
|
{
|
|
private static readonly IPAddress AddressX = IPAddress.Parse("203.0.113.10");
|
|
private static readonly IPAddress AddressY = IPAddress.Parse("203.0.113.11");
|
|
|
|
private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm;
|
|
|
|
public AuthIdTests()
|
|
{
|
|
AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2;
|
|
IncomingAccountPackets.ClearAuthIdWindow();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
IncomingAccountPackets.ClearAuthIdWindow();
|
|
AccountSecurity.CurrentAlgorithm = _originalAlgorithm;
|
|
}
|
|
|
|
private static IAccount CreateAccount(string username) =>
|
|
Accounts.GetAccount(username) ?? new Account(username, "hunter2");
|
|
|
|
private static int Register(IAccount account, IPAddress address) =>
|
|
IncomingAccountPackets.RegisterAuthId(account, address, new ClientVersion(7, 0, 0, 0));
|
|
|
|
[Fact]
|
|
public void VouchesForTheAccountAndAddressItWasIssuedTo()
|
|
{
|
|
var account = CreateAccount("authid-match-user");
|
|
var authId = Register(account, AddressX);
|
|
|
|
var result = IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out var entry);
|
|
|
|
Assert.Equal(IncomingAccountPackets.AuthIdResult.Vouched, result);
|
|
Assert.Same(account, entry.Account);
|
|
}
|
|
|
|
[Fact]
|
|
public void RejectsADifferentAccount()
|
|
{
|
|
var issued = CreateAccount("authid-owner-user");
|
|
var other = CreateAccount("authid-other-user");
|
|
var authId = Register(issued, AddressX);
|
|
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
|
IncomingAccountPackets.ConsumeAuthId(authId, other.Username, AddressX, out _)
|
|
);
|
|
}
|
|
|
|
[Fact]
|
|
public void RejectsADifferentAddress()
|
|
{
|
|
var account = CreateAccount("authid-switch-user");
|
|
var authId = Register(account, AddressX);
|
|
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _)
|
|
);
|
|
}
|
|
|
|
[Fact]
|
|
public void MatchesTheUsernameCaseInsensitively()
|
|
{
|
|
var account = CreateAccount("AuthId-Case-User");
|
|
var authId = Register(account, AddressX);
|
|
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
|
IncomingAccountPackets.ConsumeAuthId(authId, "authid-case-user", AddressX, out _)
|
|
);
|
|
}
|
|
|
|
[Fact]
|
|
public void MatchesAnIPv4MappedIPv6Address()
|
|
{
|
|
var account = CreateAccount("authid-mapped-user");
|
|
var authId = Register(account, AddressX);
|
|
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX.MapToIPv6(), out _)
|
|
);
|
|
}
|
|
|
|
[Fact]
|
|
public void RejectsAnUnknownAuthId()
|
|
{
|
|
var account = CreateAccount("authid-unknown-user");
|
|
var authId = Register(account, AddressX);
|
|
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
|
IncomingAccountPackets.ConsumeAuthId(authId + 1, account.Username, AddressX, out _)
|
|
);
|
|
}
|
|
|
|
[Fact]
|
|
public void IsSingleUseAfterASuccess()
|
|
{
|
|
var account = CreateAccount("authid-once-user");
|
|
var authId = Register(account, AddressX);
|
|
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _)
|
|
);
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _)
|
|
);
|
|
}
|
|
|
|
[Fact]
|
|
public void IsSpentEvenWhenTheAccountDoesNotMatch()
|
|
{
|
|
var account = CreateAccount("authid-spent-user");
|
|
var authId = Register(account, AddressX);
|
|
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
|
IncomingAccountPackets.ConsumeAuthId(authId, "not-the-owner", AddressX, out _)
|
|
);
|
|
|
|
// Spent regardless, so a guessed id cannot be reused to probe for its owner.
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _)
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An expired id is not a lockout. A player can sit on the server list, and before any of this
|
|
/// existed the game login always verified the password anyway -- so falling back to that verify
|
|
/// is the behaviour we started from, not a regression.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ExpiresIntoAPasswordVerifyRatherThanARejection()
|
|
{
|
|
var account = CreateAccount("authid-expired-user");
|
|
var authId = Register(account, AddressX);
|
|
|
|
var now = Core._now;
|
|
|
|
try
|
|
{
|
|
Core._now = now + TimeSpan.FromMinutes(30.0);
|
|
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Expired,
|
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out var entry)
|
|
);
|
|
|
|
// Still carries the client version the game login needs.
|
|
Assert.Equal(new ClientVersion(7, 0, 0, 0), entry.Version);
|
|
}
|
|
finally
|
|
{
|
|
Core._now = now;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AnExpiredIdFromAnotherAddressIsStillRejected()
|
|
{
|
|
var account = CreateAccount("authid-expired-elsewhere-user");
|
|
var authId = Register(account, AddressX);
|
|
|
|
var now = Core._now;
|
|
|
|
try
|
|
{
|
|
Core._now = now + TimeSpan.FromMinutes(30.0);
|
|
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _)
|
|
);
|
|
}
|
|
finally
|
|
{
|
|
Core._now = now;
|
|
}
|
|
}
|
|
|
|
private static int Ensure(int existingAuthId, IAccount account, IPAddress address) =>
|
|
IncomingAccountPackets.EnsureAuthId(
|
|
existingAuthId,
|
|
account,
|
|
address,
|
|
new ClientVersion(7, 0, 0, 0)
|
|
);
|
|
|
|
[Fact]
|
|
public void IssuesAnIdWhenTheConnectionHasNone()
|
|
{
|
|
var account = CreateAccount("authid-first-select-user");
|
|
|
|
var authId = Ensure(0, account, AddressX);
|
|
|
|
Assert.NotEqual(0, authId);
|
|
Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A connection gets exactly one id, no matter how many times it re-selects. Handing the same
|
|
/// one back rather than minting another is what makes an orphaned id impossible, instead of
|
|
/// something to clean up afterwards.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ReSelectingReturnsTheSameIdAndAddsNothingToTheWindow()
|
|
{
|
|
var account = CreateAccount("authid-reselect-user");
|
|
var first = Ensure(0, account, AddressX);
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
Assert.Equal(first, Ensure(first, account, AddressX));
|
|
}
|
|
|
|
Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount);
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
|
IncomingAccountPackets.ConsumeAuthId(first, account.Username, AddressX, out _)
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An id is only left behind when a client picks a server and never arrives. Issuing sweeps
|
|
/// those, so they do not accumulate.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AbandonedIdsAreSweptWhenNewOnesAreIssued()
|
|
{
|
|
var abandoned = CreateAccount("authid-abandoned-user");
|
|
var live = CreateAccount("authid-live-user");
|
|
|
|
var now = Core._now;
|
|
|
|
try
|
|
{
|
|
for (var i = 0; i < 128; i++)
|
|
{
|
|
Register(abandoned, AddressX);
|
|
}
|
|
|
|
Assert.Equal(128, IncomingAccountPackets.AuthIdWindowCount);
|
|
|
|
Core._now = now + TimeSpan.FromMinutes(30.0);
|
|
|
|
var liveId = Register(live, AddressX);
|
|
|
|
Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount);
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
|
IncomingAccountPackets.ConsumeAuthId(liveId, live.Username, AddressX, out _)
|
|
);
|
|
}
|
|
finally
|
|
{
|
|
Core._now = now;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A login rush is not a backlog. Every one of these ids belongs to a client on its way to
|
|
/// redeem it, so none may be discarded to keep the window at some arbitrary size -- doing so
|
|
/// hands a real player "unable to find auth id" and a disconnect.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ALoginRushDoesNotEvictAnyonesAuthId()
|
|
{
|
|
var account = CreateAccount("authid-rush-user");
|
|
var ids = new int[800];
|
|
|
|
for (var i = 0; i < ids.Length; i++)
|
|
{
|
|
ids[i] = Register(account, AddressX);
|
|
}
|
|
|
|
Assert.Equal(ids.Length, IncomingAccountPackets.AuthIdWindowCount);
|
|
|
|
// Every id issued during the rush is still redeemable, including the first one.
|
|
for (var i = 0; i < ids.Length; i++)
|
|
{
|
|
Assert.Equal(
|
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
|
IncomingAccountPackets.ConsumeAuthId(ids[i], account.Username, AddressX, out _)
|
|
);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void PreAuthenticatedGameLogin_SkipsThePasswordCheck()
|
|
{
|
|
var account = CreateAccount("authid-preauth-user");
|
|
using var ns = PacketTestUtilities.CreateTestNetState();
|
|
|
|
// A wrong password is accepted only because the auth id already vouched for the account.
|
|
var e = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", true);
|
|
GameServer.GameServerLoginEvent(e);
|
|
|
|
Assert.True(e.Accepted);
|
|
}
|
|
|
|
[Fact]
|
|
public void GameLoginWithoutPreAuthentication_StillChecksThePassword()
|
|
{
|
|
var account = CreateAccount("authid-nopreauth-user");
|
|
using var ns = PacketTestUtilities.CreateTestNetState();
|
|
|
|
var wrong = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", false);
|
|
GameServer.GameServerLoginEvent(wrong);
|
|
Assert.False(wrong.Accepted);
|
|
|
|
var right = new GameServer.GameLoginEventArgs(ns, account.Username, "hunter2", false);
|
|
GameServer.GameServerLoginEvent(right);
|
|
Assert.True(right.Accepted);
|
|
}
|
|
|
|
[Fact]
|
|
public void GeneratesDistinctAuthIds()
|
|
{
|
|
var account = CreateAccount("authid-distinct-user");
|
|
|
|
Assert.NotEqual(Register(account, AddressX), Register(account, AddressX));
|
|
}
|
|
}
|