Add alpha session gate with cookie auth and login UI.
This commit is contained in:
@@ -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 1–40 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"HSchool": {
|
||||
"AlphaPassword": "alpha",
|
||||
"SessionCookieDays": 14
|
||||
},
|
||||
"SwarmUi": {
|
||||
"BaseUrl": "http://127.0.0.1:7801",
|
||||
"Authorization": "",
|
||||
|
||||
Reference in New Issue
Block a user