Reject a second live WebSocket for the same session name.

Two tabs with the cookie already set never POST /api/session, so name-online on login did not cover the design rule. Claim the name under the same lock as the online check.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 11:04:56 +03:00
co-authored by Cursor
parent d3396b5b8f
commit 1c3ae9ab7e
7 changed files with 219 additions and 6 deletions
+31
View File
@@ -353,3 +353,34 @@
- `PhysicalEducationBeforeLesson_…` не гоняет полный цикл «зал с формой» — это в
`AppropriatenessSimulationTests.PeLesson_…`.
- Портреты/SwarmUI — вне среза 7, но `PortraitApiTests` чинился, чтобы AppHost-сборка не падала.
## Срез 8. Сессия и темп
- **Фазы:** 3840
- **Проверен на:** `pending`, 2026-08-20
- **Пути:** `src/HSchool.Server/{Session,Api/SessionEndpoints.cs,Api/SchoolAccess.cs,Game/SchoolOwnership.cs,Game/UserStore.cs,Net/ClientRegistry.cs,Net/GameSocketHandler.cs,Game/GameLoopService.cs}`, `src/HSchool.Simulation/{ClockSpeed.cs,GameClock.cs,School.cs,SimulationOptions.cs}`, `src/HSchool.Client/src/{ui/sessionGate.ts,ui/mainMenu.ts,ui/gameScreen.ts,ui/schoolCard.ts,net/protocol.ts,net/api.ts,main.ts}`, `docs/protocol.md`, `docs/design/session.md`, `tests/HSchool.AppHost.Tests/{SessionApiTests.cs,SchoolOwnerApiTests.cs,GameSocketTests.cs}`, `tests/HSchool.Simulation.Tests/{ClockTempoTests.cs,GameClockTests.cs}`, `src/HSchool.Client/src/ui/{sessionGate,gameScreen,schoolCard,mainMenu}.test.ts`
- **Итог:** обещания 38–40 на месте; дописано 4 теста; вторая вкладка с той же кукой больше не получает Welcome. Срез 9 (44/45/47) в работе — не ревьюился
Что подтверждено:
- **38:** `HSchool:AlphaPassword` (пустой — `ValidateOnStart`); `POST/GET/DELETE /api/session`; кука HttpOnly, `SameSite=Lax`, `Path=/`, `Max-Age` из `SessionCookieDays` (14); `users.json` каноническое имя; повтор другим регистром — та же запись; игровой HTTP без куки — `401`; `/health` и три ручки сессии — без неё; сокет без куки — close `PolicyViolation`, без Welcome; Hello по-прежнему версия + локаль; клиент — сначала сессия, потом сокет; хостовые тесты логинятся в `ResetAsync`/`LoginAsync`. Версия протокола не бампилась (v8).
- **39:** `MaxSchools` = 2 на игрока, `MaxSchoolsTotal` = 16 (в AppHost-тестах Total = 7); create пишет `owner`; `limit-reached` в коде — `school-limit-reached` (как в `protocol.md`); `server-full`; `GET /api/schools``schools` / `others`, `mine`, `owner` или `null`; Welcome.`MaxSchools` — слоты игрока; гость: найм `403` `not-owner`, `SetRunning` не меняет `running`; бесхозная в `others`, удаляется вторым; меню «Мои»/«Чужие»; в чужой школе нет «Управление» и кнопок часов. Портретный POST без `RequireManage`. Формат сейва не бампился.
- **40:** база 1 игровая минута/с; таблица `0.5, 1, 2, 5, 10` в `ClockSpeed`, `protocol.ts` и `protocol.md`; 20 импульсов ×1 = 1 минута, индексы 3/4 = 5 и 10 минут; на ×10 тяжёлые 10 раз с квантом 1; на ×5 — 5 раз; индекс 5 игнорируется; воркер по-прежнему 20 Гц и потолок догона 5; пять кнопок на экране. `HSchool.Simulation` без ASP.NET.
Дописано:
- `SessionApiTests.Login_SetsHttpOnlyLaxSessionCookie`
- `SessionApiTests.SecondWebSocket_ForAnOnlineName_ClosesWithoutWelcome`
- `ClockTempoTests.AtX5_TwentyImpulses_RunHeavyFiveTimesAndAdvanceFiveGameMinutes` (и календарь +1 на ×1 в `AtX1_…`)
- `mainMenu.test.ts` — чужая карточка без удаления, бесхозная с удалением
Исправлено:
- Второй WebSocket того же имени принимался и слал Welcome: `POST` проверял «онлайн», upgrade — нет. Две вкладки с живой кукой обходили `409` `name-online`. Теперь `ClientRegistry.TryClaimUserName` под тем же замком, второй сокет закрывается `PolicyViolation` без Welcome.
Открыто:
- `POST /api/session` с другим именем при живой куке перезаписывает сессию без `DELETE`. Дизайн: «Без выхода второе имя с той же куки не взять». Кода ошибки нет — новый HTTP-код молча не вводился.
- `AddDataProtection()` без ключей рядом с `saves/`. Рестарт того же профиля Windows куки, скорее всего, не сбрасывает; новый контейнер — может.
- Текст фазы 39 пишет `limit-reached`; живой код и `protocol.md``school-limit-reached` (так было до среза). Не менялось.
- Бесхозная карточка показывает «Хозяин: —», не голый прочерк. `GET /api/schools/{id}` из дизайна нет: `mine`/`owner` живут в списке меню.
+3 -2
View File
@@ -64,8 +64,9 @@ Returns `{ "userName": "Leo" }` when the cookie is valid, otherwise `401`.
Clears the session cookie. `204`.
The WebSocket at `/ws/game` uses the same cookie on upgrade. Without a valid cookie the server
closes the connection with a policy violation and never sends Welcome. Hello is unchanged
(version + locale only).
closes the connection with a policy violation and never sends Welcome. A second socket for a name
that already has a live connection is closed the same way; `POST /api/session` for that name
returns `409` `name-online`. Hello is unchanged (version + locale only).
### `GET /api/schools`
@@ -0,0 +1,74 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchSchools, type SchoolsResponse } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { MainMenu } from './mainMenu.ts';
vi.mock('../net/api.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../net/api.ts')>();
return {
...actual,
fetchSchools: vi.fn(),
};
});
describe('MainMenu ownership', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
afterEach(() => {
vi.restoreAllMocks();
});
it('hides delete on another owners card and shows it on an ownerless one', async () => {
vi.mocked(fetchSchools).mockResolvedValue(menuState());
const menu = new MainMenu({
onOpenSchool: () => {},
onLogout: () => {},
});
document.body.append(menu.element);
await menu.refresh();
const foreign = menu.element.querySelector('[data-school-id="2"]');
const orphan = menu.element.querySelector('[data-school-id="3"]');
expect((foreign?.querySelector('.button--danger') as HTMLElement).hidden).toBe(true);
expect((orphan?.querySelector('.button--danger') as HTMLElement).hidden).toBe(false);
expect(foreign?.textContent).toContain('Bob');
expect(orphan?.textContent).toContain(t('schoolOwnerless'));
});
});
function menuState(): SchoolsResponse {
return {
maxSchools: 2,
maxSchoolsTotal: 16,
defaultStartDate: '2012-03-31T06:00:00.000Z',
gameMinutesPerRealSecond: 1,
schoolWeekDays: 5,
schools: [],
others: [
{
id: 2,
name: 'Foreign',
gameTime: '2012-03-31T06:00:00.000Z',
running: true,
speedIndex: 1,
seed: 1,
owner: 'Bob',
},
{
id: 3,
name: 'Orphan',
gameTime: '2012-03-31T06:00:00.000Z',
running: true,
speedIndex: 1,
seed: 2,
owner: null,
},
],
};
}
+30 -3
View File
@@ -7,6 +7,7 @@ namespace HSchool.Server.Net;
internal sealed class ClientRegistry
{
private readonly ConcurrentDictionary<uint, GameClient> _clients = new();
private readonly object _names = new();
private uint _nextPlayerId;
public int Count => _clients.Count;
@@ -28,14 +29,40 @@ internal sealed class ClientRegistry
public bool IsUserNameOnline(string normalizedUserName)
{
foreach (var client in _clients.Values)
lock (_names)
{
if (client.UserName is null)
return HasUserName(normalizedUserName);
}
}
/// <summary>
/// One live socket per name. Checked under the same lock as <see cref="IsUserNameOnline"/>
/// so a second tab cannot sneak a Welcome in between the HTTP check and SetUserName.
/// </summary>
public bool TryClaimUserName(GameClient client, string userName)
{
lock (_names)
{
if (HasUserName(userName))
{
return false;
}
client.SetUserName(userName);
return true;
}
}
private bool HasUserName(string normalizedUserName)
{
foreach (var existing in _clients.Values)
{
if (existing.UserName is null)
{
continue;
}
if (string.Equals(client.NormalizedUserName, normalizedUserName, StringComparison.OrdinalIgnoreCase))
if (string.Equals(existing.NormalizedUserName, normalizedUserName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
+10 -1
View File
@@ -23,7 +23,6 @@ internal sealed class GameSocketHandler(
public async Task HandleAsync(WebSocket socket, string userName, CancellationToken cancellationToken)
{
var client = clients.Add(socket);
client.SetUserName(userName);
var buffer = ArrayPool<byte>.Shared.Rent(ProtocolConstants.MaxMessageSize);
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
@@ -32,6 +31,16 @@ internal sealed class GameSocketHandler(
try
{
if (!clients.TryClaimUserName(client, userName))
{
await CloseAsync(
socket,
WebSocketCloseStatus.PolicyViolation,
"Name is already online.",
cancellationToken).ConfigureAwait(false);
return;
}
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(connectionCts.Token);
handshakeCts.CancelAfter(HandshakeTimeout);
@@ -111,6 +111,26 @@ public class SessionApiTests(AppHostFixture fixture)
}
}
[Fact]
public async Task Login_SetsHttpOnlyLaxSessionCookie()
{
using var client = fixture.App.CreateHttpClient("server");
using var response = await client.PostAsJsonAsync(
"/api/session",
new { password = SchoolApiTests.TestPassword, userName = $"Cookie-{Guid.NewGuid():N}"[..14] },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
Assert.True(response.Headers.TryGetValues("Set-Cookie", out var values));
var cookie = Assert.Single(values, value =>
value.StartsWith("hschool.session=", StringComparison.OrdinalIgnoreCase));
var parts = cookie.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
Assert.Contains(parts, part => part.Equals("httponly", StringComparison.OrdinalIgnoreCase));
Assert.Contains(parts, part => part.Equals("samesite=lax", StringComparison.OrdinalIgnoreCase));
Assert.Contains(parts, part => part.Equals("path=/", StringComparison.OrdinalIgnoreCase));
Assert.Contains(parts, part => part.Equals("max-age=1209600", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task WebSocket_WithoutSession_ClosesWithoutWelcome()
{
@@ -123,6 +143,40 @@ public class SessionApiTests(AppHostFixture fixture)
Assert.Equal(WebSocketCloseStatus.PolicyViolation, socket.CloseStatus);
}
[Fact]
public async Task SecondWebSocket_ForAnOnlineName_ClosesWithoutWelcome()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var name = $"DupWs-{Guid.NewGuid():N}"[..14];
var cookie = await SchoolApiTests.LoginAndGetCookieAsync(client, name);
var first = new ClientWebSocket();
first.Options.SetRequestHeader("Cookie", cookie);
var http = fixture.App.GetEndpoint("server", "http");
var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri;
await first.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
try
{
await SendHelloAsync(first);
await ReceiveWelcomeAsync(first);
using var second = new ClientWebSocket();
second.Options.SetRequestHeader("Cookie", cookie);
await second.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
await SendHelloAsync(second);
var frame = await ReceiveOneFrameAsync(second, TimeSpan.FromSeconds(5));
Assert.Equal(WebSocketMessageType.Close, frame.MessageType);
Assert.Equal(WebSocketCloseStatus.PolicyViolation, second.CloseStatus);
}
finally
{
first.Dispose();
}
}
private HttpClient CreateAnonymousClient()
{
var http = fixture.App.GetEndpoint("server", "http").ToString();
@@ -34,9 +34,26 @@ public class ClockTempoTests
school.Tick(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
Assert.Equal(Start.AddMinutes(1), school.Clock.Time);
Assert.Equal(20, school.HeavySystemsInvocations);
}
[Fact]
public void AtX5_TwentyImpulses_RunHeavyFiveTimesAndAdvanceFiveGameMinutes()
{
using var school = School.Create(1, "Страйд ×5", Start);
school.Clock.SpeedIndex = 3;
for (var i = 0; i < 20; i++)
{
school.Tick(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
Assert.Equal(Start.AddMinutes(5), school.Clock.Time);
Assert.Equal(5, school.HeavySystemsInvocations);
Assert.Equal(1d, school.LastHeavyGameMinutes);
}
[Theory]
[InlineData(3, 5d)]
[InlineData(4, 10d)]