signup codes: admin-issued invite codes + site /join redemption

While account creation is disabled, the only way in is now an
admin-issued single-use signup code (Discord-invite style), redeemed on
the public site's /join page.

Because the 2020 watch login is device-id-bound with no code field, the
flow captures it server-side instead: a new device refused at the
signups-disabled gate (PlatformLogin / account create) is stashed in a
PendingDevices table keyed by the real client IP (CF-Connecting-IP aware).
The /join page then shows the player the device their own client just
reported, they enter their code + username, and the account is minted
bound to that device — so the next launch logs straight in via the
existing device-id path. No watch changes.

- SignupCodeEntity (code, descriptor, optional expiry, redeemer) +
  PendingDeviceEntity; idempotent CREATE TABLE in DatabaseBootstrap so
  both providers pick them up without a new migration.
- SignupCodeService: generate / list / revoke / redeem + pending-device
  capture, single-use + expiry + revoke validation.
- Admin: GET/POST signup-codes + revoke endpoints and a Signup codes
  admin-ui page (generate with descriptor/expiry, status table, revoke).
- Public: anonymous /api/site/v1/join pending-devices + redeem, and a
  /join site page (+ header link).
- README documents the flow + the post-Initial table-create approach.
This commit is contained in:
Alexa
2026-05-31 20:00:35 +03:00
parent 18989fa6ec
commit 3480ca21fa
18 changed files with 830 additions and 0 deletions
@@ -0,0 +1,186 @@
using System.Security.Cryptography;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Services;
/// <summary>
/// Backs the admin-issued signup-code flow. While the server has signups
/// disabled, the only path to a new account is redeeming one of these on
/// the public <c>/join</c> page. Codes are single-use, carry an admin
/// descriptor, and can have an optional expiry. Redemption mints an
/// account bound to the device id the player's game client reported (see
/// <see cref="PendingDeviceEntity"/>), so the next launch logs straight
/// in via the watch's device-id login.
/// </summary>
public class SignupCodeService(DorkNetDbContext db, PlayerService players)
{
// Unambiguous alphabet — no 0/O/1/I/L so codes are easy to read aloud
// and retype. Format: XXXX-XXXX.
private const string Alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
public async Task<SignupCodeEntity> GenerateAsync(string descriptor, DateTime? expiresAt, long createdByPlayerId)
{
string code;
do { code = NewCode(); }
while (await db.SignupCodes.AnyAsync(c => c.Code == code));
var entity = new SignupCodeEntity
{
Code = code,
Descriptor = (descriptor ?? string.Empty).Trim(),
CreatedByPlayerId = createdByPlayerId,
CreatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt,
};
db.SignupCodes.Add(entity);
await db.SaveChangesAsync();
return entity;
}
public async Task<List<SignupCodeEntity>> ListAsync(int take = 200) =>
await db.SignupCodes
.OrderByDescending(c => c.CreatedAt)
.Take(Math.Clamp(take, 1, 1000))
.ToListAsync();
/// <summary>Revoke an unused code. Already-redeemed codes can't be
/// revoked (the account already exists); returns false in that
/// case.</summary>
public async Task<bool> RevokeAsync(long id)
{
var code = await db.SignupCodes.FirstOrDefaultAsync(c => c.Id == id);
if (code is null || code.RedeemedByPlayerId is not null) return false;
if (code.Revoked) return true;
code.Revoked = true;
await db.SaveChangesAsync();
return true;
}
public sealed record RedeemResult(bool Ok, string? Error, long? PlayerId, string? Username);
/// <summary>Redeem a code: validate it, then mint an account bound to
/// <paramref name="deviceId"/> with the requested username. The code
/// is consumed atomically with account creation.</summary>
public async Task<RedeemResult> RedeemAsync(string? rawCode, string? rawUsername, string? deviceId)
{
var code = NormalizeCode(rawCode);
if (string.IsNullOrEmpty(code)) return new(false, "missing_code", null, null);
var username = (rawUsername ?? string.Empty).Trim();
if (!IsValidUsername(username)) return new(false, "invalid_username", null, null);
if (string.IsNullOrWhiteSpace(deviceId)) return new(false, "missing_device", null, null);
var entity = await db.SignupCodes.FirstOrDefaultAsync(c => c.Code == code);
if (entity is null) return new(false, "invalid_code", null, null);
if (entity.Revoked) return new(false, "code_revoked", null, null);
if (entity.RedeemedByPlayerId is not null) return new(false, "code_used", null, null);
if (entity.ExpiresAt is { } exp && exp <= DateTime.UtcNow) return new(false, "code_expired", null, null);
if (await db.Players.AnyAsync(p => p.Username == username))
return new(false, "username_taken", null, null);
// The device must not already own an account (otherwise the
// player would launch into the existing one, not this fresh
// account). Guides them to a different device / to just log in.
if (await players.GetByDeviceAsync(deviceId) is not null)
return new(false, "device_in_use", null, null);
var player = await players.GetOrCreateByDeviceAsync(
deviceId: deviceId,
platform: 0,
platformId: null,
displayName: username);
entity.RedeemedByPlayerId = player.Id;
entity.RedeemedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// The device is now bound to a real account — drop any pending
// refusal row so it stops showing on the /join picker.
var pending = await db.PendingDevices.FirstOrDefaultAsync(d => d.DeviceId == deviceId);
if (pending is not null)
{
db.PendingDevices.Remove(pending);
await db.SaveChangesAsync();
}
return new(true, null, player.Id, player.Username);
}
/// <summary>Record a device that was refused account creation while
/// signups are disabled, so the /join page can surface it (matched by
/// IP) instead of making the player hunt for their device id.
/// Upserts by device id.</summary>
public async Task RecordPendingDeviceAsync(string? deviceId, int platform, string? platformId, string? ip)
{
if (string.IsNullOrWhiteSpace(deviceId)) return;
var existing = await db.PendingDevices.FirstOrDefaultAsync(d => d.DeviceId == deviceId);
var now = DateTime.UtcNow;
if (existing is null)
{
db.PendingDevices.Add(new PendingDeviceEntity
{
DeviceId = deviceId,
Platform = platform,
PlatformId = platformId ?? string.Empty,
LastIp = ip,
FirstSeenAt = now,
LastSeenAt = now,
});
}
else
{
existing.Platform = platform;
if (!string.IsNullOrEmpty(platformId)) existing.PlatformId = platformId;
existing.LastIp = ip;
existing.LastSeenAt = now;
}
await db.SaveChangesAsync();
}
/// <summary>Devices recently refused from the given IP, newest first —
/// the /join picker shows these so the player recognises their own.</summary>
public async Task<List<PendingDeviceEntity>> RecentPendingByIpAsync(string? ip, int take = 10)
{
if (string.IsNullOrWhiteSpace(ip)) return new();
return await db.PendingDevices
.Where(d => d.LastIp == ip)
.OrderByDescending(d => d.LastSeenAt)
.Take(Math.Clamp(take, 1, 50))
.ToListAsync();
}
/// <summary>Best-effort real client IP. The game client and the
/// player's browser both sit behind the same Cloudflare edge / tunnel,
/// so <c>RemoteIpAddress</c> collapses every caller to one address;
/// prefer the CF-Connecting-IP / X-Forwarded-For header so the /join
/// picker can match a player to the device their own client
/// reported.</summary>
public static string? ClientIp(HttpContext ctx)
{
var cf = ctx.Request.Headers["CF-Connecting-IP"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(cf)) return cf.Trim();
var fwd = ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(fwd)) return fwd.Split(',')[0].Trim();
return ctx.Connection.RemoteIpAddress?.ToString();
}
private static string NewCode()
{
Span<char> buf = stackalloc char[9];
for (var i = 0; i < 9; i++)
buf[i] = i == 4 ? '-' : Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)];
return new string(buf);
}
private static string NormalizeCode(string? raw) =>
(raw ?? string.Empty).Trim().ToUpperInvariant();
private static bool IsValidUsername(string username) =>
username.Length is >= 2 and <= 24
&& username.All(ch => char.IsLetterOrDigit(ch) || ch is '_' or '-');
}