Add alpha session gate with cookie auth and login UI.

This commit is contained in:
Leonid Pershin
2026-08-20 07:17:18 +03:00
parent 2a55a025f4
commit a444fc011e
24 changed files with 1005 additions and 27 deletions
+1
View File
@@ -16,6 +16,7 @@ if (headless)
server
.WithEnvironment("Simulation__SavesDirectory", saves)
.WithEnvironment("HSchool__AllowSaveReload", "true")
.WithEnvironment("HSchool__AlphaPassword", "test-alpha")
.WithEnvironment("SwarmUi__BaseUrl", "");
}
+26
View File
@@ -9,6 +9,19 @@ const ru = {
pingPlaceholder: '-- мс',
language: 'Язык',
sessionTitle: 'Вход',
sessionPassword: 'Пароль альфы',
sessionPasswordPlaceholder: 'Пароль',
sessionUserName: 'Ваше имя',
sessionUserNamePlaceholder: 'Имя в сети',
sessionContinue: 'Продолжить',
sessionEnter: 'Войти',
sessionBadPassword: 'Неверный пароль.',
sessionNameOnline: 'Это имя уже в сети.',
sessionInvalidName: 'Имя должно быть от 1 до 40 символов.',
sessionFailed: 'Не удалось войти. Попробуйте ещё раз.',
sessionLogout: 'Выйти',
schoolsTitle: 'Школы',
createSchool: 'Создать школу',
settings: 'Настройки',
@@ -338,6 +351,19 @@ const en: Messages = {
pingPlaceholder: '-- ms',
language: 'Language',
sessionTitle: 'Sign in',
sessionPassword: 'Alpha password',
sessionPasswordPlaceholder: 'Password',
sessionUserName: 'Your name',
sessionUserNamePlaceholder: 'Display name',
sessionContinue: 'Continue',
sessionEnter: 'Enter',
sessionBadPassword: 'Wrong password.',
sessionNameOnline: 'That name is already online.',
sessionInvalidName: 'The name must be 140 characters.',
sessionFailed: 'Could not sign in. Try again.',
sessionLogout: 'Sign out',
schoolsTitle: 'Schools',
createSchool: 'Create school',
settings: 'Settings',
+19 -3
View File
@@ -1,9 +1,11 @@
import { GameConnection, gameSocketUrl, type ConnectionStatus } from './net/connection.ts';
import { logoutSession } from './net/api.ts';
import { getLocale, onLocaleChange } from './i18n/locale.ts';
import { t, type MessageKey } from './i18n/strings.ts';
import { GameScreen } from './ui/gameScreen.ts';
import { localeSwitch } from './ui/localeSwitch.ts';
import { MainMenu } from './ui/mainMenu.ts';
import { ensureSession } from './ui/sessionGate.ts';
import type { School } from './net/api.ts';
import './style.css';
@@ -15,7 +17,7 @@ const STATUS_KEYS: Record<ConnectionStatus, MessageKey> = {
};
/** Wires the two screens to one WebSocket connection. */
function bootstrap(): void {
async function bootstrap(): Promise<void> {
const app = requireElement('#app');
const footer = requireElement('#status');
const statusLabel = document.querySelector<HTMLElement>('[data-status="connection"]');
@@ -23,11 +25,16 @@ function bootstrap(): void {
footer.prepend(localeSwitch());
await ensureSession();
let openSchool: School | null = null;
let connectionStatus: ConnectionStatus = 'connecting';
let lastPingMs: number | null = null;
const menu = new MainMenu({ onOpenSchool: (school) => enterSchool(school) });
const menu = new MainMenu({
onOpenSchool: (school) => enterSchool(school),
onLogout: () => void handleLogout(),
});
const game = new GameScreen({
onLeave: () => leaveSchool(),
onSetRunning: (running) => connection.setRunning(running),
@@ -97,6 +104,15 @@ function bootstrap(): void {
menu.start();
}
async function handleLogout(): Promise<void> {
connection.close();
menu.stop();
await logoutSession();
await ensureSession();
connection.connect();
showMenu();
}
onLocaleChange(() => {
paintChrome();
menu.localize();
@@ -120,4 +136,4 @@ function requireElement(selector: string): HTMLElement {
return element;
}
bootstrap();
void bootstrap();
+21 -1
View File
@@ -402,6 +402,26 @@ export async function fetchGameStatus(): Promise<GameStatus> {
return request<GameStatus>('/api/status');
}
export interface SessionInfo {
readonly userName: string;
}
export async function fetchSession(): Promise<SessionInfo> {
return request<SessionInfo>('/api/session');
}
export async function loginSession(password: string, userName: string): Promise<SessionInfo> {
return request<SessionInfo>('/api/session', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password, userName }),
});
}
export async function logoutSession(): Promise<void> {
await request<void>('/api/session', { method: 'DELETE' }, { expectBody: false });
}
export interface SwarmUiKindPreset {
width: number;
height: number;
@@ -764,7 +784,7 @@ async function request<T>(
init?: RequestInit,
options: { expectBody?: boolean } = {},
): Promise<T> {
const response = await fetch(url, init);
const response = await fetch(url, { credentials: 'include', ...init });
if (!response.ok) {
throw await toApiError(response);
+8 -1
View File
@@ -16,6 +16,7 @@ import { SchoolCard } from './schoolCard.ts';
interface MainMenuOptions {
readonly onOpenSchool: (school: School) => void;
readonly onLogout: () => void;
}
/**
@@ -40,6 +41,10 @@ export class MainMenu {
class: 'button',
type: 'button',
});
private readonly logoutButton = el('button', {
class: 'button',
type: 'button',
});
private readonly limitHint = el('p', { class: 'hint' });
private readonly status = el('p', { class: 'hint hint--error' });
@@ -53,6 +58,7 @@ export class MainMenu {
constructor(private readonly options: MainMenuOptions) {
this.createButton.addEventListener('click', () => void this.openCreateDialog());
this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog());
this.logoutButton.addEventListener('click', () => this.options.onLogout());
this.status.hidden = true;
this.root.append(
@@ -60,7 +66,7 @@ export class MainMenu {
'header',
{ class: 'screen__header' },
this.title,
el('div', { class: 'screen__actions' }, this.settingsButton, this.createButton),
el('div', { class: 'screen__actions' }, this.logoutButton, this.settingsButton, this.createButton),
),
this.limitHint,
this.status,
@@ -80,6 +86,7 @@ export class MainMenu {
this.title.textContent = t('schoolsTitle');
this.createButton.textContent = t('createSchool');
this.settingsButton.textContent = t('settings');
this.logoutButton.textContent = t('sessionLogout');
this.emptyHint.textContent = t('emptySchools');
for (const card of this.cards.values()) {
@@ -0,0 +1,40 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiError, fetchSchools, fetchSession } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { ensureSession } from './sessionGate.ts';
vi.mock('../net/api.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../net/api.ts')>();
return {
...actual,
fetchSession: vi.fn(),
fetchSchools: vi.fn(),
loginSession: vi.fn(),
};
});
describe('session gate', () => {
beforeEach(() => {
document.body.innerHTML = '<main id="app"></main>';
});
afterEach(() => {
vi.restoreAllMocks();
});
it('shows the password step when there is no session cookie', async () => {
vi.mocked(fetchSession).mockRejectedValue(new ApiError(401, 'unknown', 'Unauthorized'));
const pending = ensureSession();
await Promise.resolve();
const app = document.querySelector('#app');
expect(app?.textContent).toContain(t('sessionPassword'));
expect(app?.textContent).not.toContain(t('schoolsTitle'));
pending.catch(() => undefined);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { ApiError, fetchSession, loginSession } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
/**
* Blocks until the player has a live session cookie. Returns the signed-in name.
*/
export async function ensureSession(): Promise<string> {
try {
const session = await fetchSession();
return session.userName;
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 401) {
throw error;
}
}
return showSessionGate();
}
function showSessionGate(): Promise<string> {
const app = document.querySelector<HTMLElement>('#app');
if (app === null) {
throw new Error('#app is missing from index.html.');
}
return new Promise((resolve) => {
let password = '';
const root = el('section', { class: 'screen session-gate' });
const title = el('h1', { class: 'screen__title' });
const passwordLabel = el('label', { class: 'field' });
const passwordInput = el('input', {
class: 'field__input',
type: 'password',
autocomplete: 'current-password',
}) as HTMLInputElement;
const nameLabel = el('label', { class: 'field' });
const nameInput = el('input', {
class: 'field__input',
type: 'text',
autocomplete: 'username',
maxlength: '40',
}) as HTMLInputElement;
const submit = el('button', { class: 'button button--primary', type: 'submit' });
const error = el('p', { class: 'hint hint--error' });
error.hidden = true;
passwordLabel.append(el('span', { class: 'field__label' }), passwordInput);
nameLabel.append(el('span', { class: 'field__label' }), nameInput);
nameLabel.hidden = true;
const form = el(
'form',
{ class: 'session-gate__form' },
passwordLabel,
nameLabel,
error,
submit,
);
form.addEventListener('submit', (event) => {
event.preventDefault();
void submitStep();
});
root.append(title, form);
app.replaceChildren(root);
paint();
async function submitStep(): Promise<void> {
error.hidden = true;
submit.toggleAttribute('disabled', true);
try {
if (nameLabel.hidden) {
password = passwordInput.value;
nameLabel.hidden = false;
submit.textContent = t('sessionEnter');
nameInput.focus();
return;
}
const session = await loginSession(password, nameInput.value.trim());
resolve(session.userName);
} catch (caught) {
if (caught instanceof ApiError) {
if (caught.code === 'bad-password') {
error.textContent = t('sessionBadPassword');
passwordInput.focus();
} else if (caught.code === 'invalid-name') {
error.textContent = t('sessionInvalidName');
nameInput.focus();
} else if (caught.code === 'name-online') {
error.textContent = t('sessionNameOnline');
nameInput.focus();
} else {
error.textContent = t('sessionFailed');
}
} else {
error.textContent = t('sessionFailed');
}
error.hidden = false;
} finally {
submit.toggleAttribute('disabled', false);
}
}
function paint(): void {
title.textContent = t('sessionTitle');
passwordLabel.querySelector('.field__label')!.textContent = t('sessionPassword');
passwordInput.placeholder = t('sessionPasswordPlaceholder');
nameLabel.querySelector('.field__label')!.textContent = t('sessionUserName');
nameInput.placeholder = t('sessionUserNamePlaceholder');
submit.textContent = nameLabel.hidden ? t('sessionContinue') : t('sessionEnter');
}
passwordInput.focus();
});
}
@@ -0,0 +1,78 @@
using HSchool.Server.Session;
namespace HSchool.Server.Api;
internal static class SessionEndpoints
{
public static void MapSessionEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/api/session");
group.MapPost("/", LoginAsync);
group.MapGet("/", GetAsync);
group.MapDelete("/", LogoutAsync);
}
private static IResult LoginAsync(
LoginRequest request,
HttpContext context,
SessionService sessions)
{
if (!sessions.VerifyPassword(request.Password))
{
return Problem(StatusCodes.Status401Unauthorized, "bad-password", "The alpha password is wrong.");
}
if (!sessions.TryNormalizeUserName(request.UserName, out var normalized))
{
return Problem(
StatusCodes.Status400BadRequest,
"invalid-name",
"The name must be 140 characters after trimming, with no control characters.");
}
if (sessions.IsNameOnline(normalized))
{
return Problem(
StatusCodes.Status409Conflict,
"name-online",
"Someone with that name is already connected.");
}
var canonical = sessions.RegisterUser(normalized, normalized);
var token = sessions.CreateSessionToken(canonical);
context.Response.Cookies.Append(SessionService.CookieName, token, sessions.BuildCookieOptions(context));
return Results.Json(new SessionResponse(canonical));
}
private static IResult GetAsync(HttpContext context, SessionService sessions)
{
if (!sessions.TryGetUserName(context, out var userName))
{
return Results.Unauthorized();
}
return Results.Json(new SessionResponse(userName));
}
private static IResult LogoutAsync(HttpContext context, SessionService sessions)
{
context.Response.Cookies.Delete(SessionService.CookieName, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
});
return Results.NoContent();
}
private static IResult Problem(int statusCode, string code, string detail)
{
var extensions = new Dictionary<string, object?> { ["code"] = code };
return Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: extensions);
}
private sealed record LoginRequest(string Password, string UserName);
private sealed record SessionResponse(string UserName);
}
+107
View File
@@ -0,0 +1,107 @@
using System.Text.Json;
namespace HSchool.Server.Game;
/// <summary>One registered player name, stored exactly as typed on first login.</summary>
internal sealed record UserRecord(string Name);
/// <summary>Persistent user list beside school saves.</summary>
internal sealed class UserStore
{
private const string UsersFileName = "users.json";
private static readonly JsonSerializerOptions Json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true,
};
private readonly object _gate = new();
private readonly ILogger<UserStore> _logger;
private readonly string _path;
private List<UserRecord> _users = [];
public UserStore(SchoolStore schools, ILogger<UserStore> logger)
{
_logger = logger;
_path = Path.Combine(schools.DirectoryPath, UsersFileName);
Load();
}
/// <summary>
/// Returns the canonical spelling for <paramref name="normalized"/> or registers
/// <paramref name="displayName"/> on first use.
/// </summary>
public string ResolveOrRegister(string normalized, string displayName)
{
lock (_gate)
{
var existing = FindCanonicalLocked(normalized);
if (existing is not null)
{
return existing;
}
_users.Add(new UserRecord(displayName));
SaveLocked();
return displayName;
}
}
public bool TryFindCanonical(string normalized, out string canonical)
{
lock (_gate)
{
canonical = FindCanonicalLocked(normalized) ?? "";
return canonical.Length > 0;
}
}
private string? FindCanonicalLocked(string normalized)
{
foreach (var user in _users)
{
if (string.Equals(user.Name, normalized, StringComparison.OrdinalIgnoreCase))
{
return user.Name;
}
}
return null;
}
private void Load()
{
if (!File.Exists(_path))
{
return;
}
try
{
var document = JsonSerializer.Deserialize<UserDocument>(File.ReadAllText(_path), Json);
_users = document?.Users?.ToList() ?? [];
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not read {Path}; starting with an empty user list.", _path);
_users = [];
}
}
private void SaveLocked()
{
WriteAtomic(_path, new UserDocument(_users));
}
private static void WriteAtomic(string path, UserDocument document)
{
var json = JsonSerializer.Serialize(document, Json);
var temp = path + ".tmp";
File.WriteAllText(temp, json);
File.Move(temp, path, overwrite: true);
}
private sealed record UserDocument(IReadOnlyList<UserRecord> Users);
}
+16
View File
@@ -0,0 +1,16 @@
namespace HSchool.Server;
/// <summary>Server-wide options that are not part of the simulation.</summary>
internal sealed class HSchoolOptions
{
public const string SectionName = "HSchool";
/// <summary>Shared alpha gate password. Empty means the process must not start.</summary>
public string AlphaPassword { get; set; } = "";
/// <summary>How long a session cookie lives without re-login.</summary>
public int SessionCookieDays { get; set; } = 14;
/// <summary>When true, dev reload/dump endpoints are mapped. Off in production by default.</summary>
public bool AllowSaveReload { get; set; }
}
+18
View File
@@ -25,4 +25,22 @@ internal sealed class ClientRegistry
public GameClient? Find(uint playerId) => _clients.GetValueOrDefault(playerId);
public void Remove(uint playerId) => _clients.TryRemove(playerId, out _);
public bool IsUserNameOnline(string normalizedUserName)
{
foreach (var client in _clients.Values)
{
if (client.UserName is null)
{
continue;
}
if (string.Equals(client.NormalizedUserName, normalizedUserName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
+14
View File
@@ -30,11 +30,25 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
private bool _ready;
private int _openSchoolId;
private int _locale;
private string? _userName;
private string? _normalizedUserName;
public uint PlayerId { get; } = playerId;
public WebSocket Socket { get; } = socket;
/// <summary>Display name from the session cookie, set before the welcome frame goes out.</summary>
public string? UserName => Volatile.Read(ref _userName);
/// <summary>Case-insensitive key used for the online-name check.</summary>
public string? NormalizedUserName => Volatile.Read(ref _normalizedUserName);
public void SetUserName(string userName)
{
Volatile.Write(ref _userName, userName);
Volatile.Write(ref _normalizedUserName, userName);
}
/// <summary>
/// Set once the welcome frame is out. Clock frames are only queued for ready clients, so a
/// connection never sees game state before the handshake finished.
+2 -1
View File
@@ -19,9 +19,10 @@ internal sealed class GameSocketHandler(
{
private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(5);
public async Task HandleAsync(WebSocket socket, CancellationToken cancellationToken)
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);
+38 -3
View File
@@ -3,6 +3,7 @@ using HSchool.Server;
using HSchool.Server.Api;
using HSchool.Server.Game;
using HSchool.Server.Net;
using HSchool.Server.Session;
using HSchool.Simulation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
@@ -12,6 +13,14 @@ var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddDataProtection();
builder.Services
.AddOptions<HSchoolOptions>()
.Bind(builder.Configuration.GetSection(HSchoolOptions.SectionName))
.Validate(options => !string.IsNullOrWhiteSpace(options.AlphaPassword), "HSchool:AlphaPassword must be set.")
.Validate(options => options.SessionCookieDays is > 0 and <= 365, "HSchool:SessionCookieDays must be between 1 and 365.")
.ValidateOnStart();
builder.Services
.AddOptions<SimulationOptions>()
@@ -30,6 +39,8 @@ builder.Services
builder.Services.AddSingleton<GameCommandQueue>();
builder.Services.AddSingleton<ClientRegistry>();
builder.Services.AddSingleton<UserStore>();
builder.Services.AddSingleton<SessionService>();
builder.Services.AddSingleton<GameMetrics>();
builder.Services.AddSingleton<SchoolStore>();
builder.Services.AddSingleton<ModContent>();
@@ -64,6 +75,9 @@ app.UseWebSockets(new WebSocketOptions
KeepAliveInterval = TimeSpan.FromSeconds(30),
});
app.UseMiddleware<SessionAuthMiddleware>();
app.MapSessionEndpoints();
app.MapSchoolEndpoints();
app.MapSettingsEndpoints();
app.MapTimetableEndpoints();
@@ -104,7 +118,7 @@ if (app.Configuration.GetValue("HSchool:AllowSaveReload", false))
}
// The realtime channel: one binary frame per protocol message, see docs/protocol.md.
app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) =>
app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler, SessionService sessions) =>
{
if (!context.WebSockets.IsWebSocketRequest)
{
@@ -113,8 +127,29 @@ app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) =>
return;
}
using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
await handler.HandleAsync(socket, context.RequestAborted);
if (!sessions.TryGetUserName(context, out var userName))
{
using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
try
{
await socket.CloseAsync(
WebSocketCloseStatus.PolicyViolation,
"Session required.",
context.RequestAborted);
}
catch (WebSocketException)
{
// The peer may already be gone.
}
}
return;
}
using WebSocket connected = await context.WebSockets.AcceptWebSocketAsync();
await handler.HandleAsync(connected, userName, context.RequestAborted);
});
app.MapDefaultEndpoints();
@@ -0,0 +1,38 @@
namespace HSchool.Server.Session;
/// <summary>
/// Every game HTTP route needs a session cookie. Health and the three session routes are the
/// only public exceptions.
/// </summary>
internal sealed class SessionAuthMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, SessionService sessions)
{
var path = context.Request.Path;
if (!path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase)
|| IsPublicApi(path))
{
await next(context);
return;
}
if (!sessions.TryGetUserName(context, out _))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
await next(context);
}
private static bool IsPublicApi(PathString path)
{
if (path.Equals("/api/session", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return false;
}
}
@@ -0,0 +1,75 @@
using HSchool.Server.Game;
using HSchool.Server.Net;
using HSchool.Simulation;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
namespace HSchool.Server.Session;
/// <summary>Alpha login, signed session cookies, and online-name checks.</summary>
internal sealed class SessionService(
IDataProtectionProvider dataProtection,
UserStore users,
ClientRegistry clients,
IOptions<HSchoolOptions> options)
{
public const string CookieName = "hschool.session";
private readonly IDataProtector _protector = dataProtection.CreateProtector("HSchool.Session.v1");
private readonly HSchoolOptions _options = options.Value;
public bool TryGetUserName(HttpContext context, out string userName)
{
userName = "";
if (!context.Request.Cookies.TryGetValue(CookieName, out var token)
|| string.IsNullOrWhiteSpace(token))
{
return false;
}
try
{
userName = _protector.Unprotect(token);
return userName.Length > 0;
}
catch
{
return false;
}
}
public CookieOptions BuildCookieOptions(HttpContext context)
{
var secure = context.Request.IsHttps;
return new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
MaxAge = TimeSpan.FromDays(_options.SessionCookieDays),
IsEssential = true,
Secure = secure,
};
}
public string CreateSessionToken(string userName) => _protector.Protect(userName);
public bool VerifyPassword(string password) =>
string.Equals(password, _options.AlphaPassword, StringComparison.Ordinal);
public bool TryNormalizeUserName(string? userName, out string normalized)
{
if (!SchoolNames.TryNormalize(userName, out normalized))
{
return false;
}
return true;
}
public string RegisterUser(string normalized, string displayName) =>
users.ResolveOrRegister(normalized, displayName);
public bool IsNameOnline(string normalizedUserName) =>
clients.IsUserNameOnline(normalizedUserName);
}
+4
View File
@@ -6,6 +6,10 @@
}
},
"AllowedHosts": "*",
"HSchool": {
"AlphaPassword": "alpha",
"SessionCookieDays": 14
},
"SwarmUi": {
"BaseUrl": "http://127.0.0.1:7801",
"Authorization": "",