feat: OTP rate limit, private-room invite UX, in-game UI fixes
Auth / security
- Rate-limit real SMS OTP sends (dev mode unlimited): 60s resend cooldown,
5 per phone/hour, 300/hour global backstop. OtpService.CheckAndRecordRate;
POST /api/auth/otp/request returns 429 {error,retryAfter}; AuthScreen shows
auth.rateLimited. Knobs in appsettings Sms (Sms__* env).
Private rooms (invite)
- Cancel-invite button on pending seats; friend picker shows presence
(online/offline/in-game, sorted online-first) and flags in-game players.
- Mock invite stays pending ~3.5s and a cancel truly stops the auto-accept
(was a bug that re-seated cancelled invites).
In-game UI
- Scoreboard is compact + shrink-safe (no overflow on narrow screens).
- Played trick cards land dead-center (were ~2px off the corner anchor).
Plus the in-flight typing-indicator work (GameHub, ChatScreen).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,8 +16,19 @@ public sealed class SmsOptions
|
||||
public bool DevMode { get; set; } = false;
|
||||
public string DevCode { get; set; } = "1234";
|
||||
public int TtlSeconds { get; set; } = 120;
|
||||
|
||||
/* --- Rate limiting (applies to real SMS sends only; dev mode is unlimited) --- */
|
||||
/// <summary>Minimum seconds between two OTP sends to the same phone (resend cooldown).</summary>
|
||||
public int ResendCooldownSeconds { get; set; } = 60;
|
||||
/// <summary>Max OTP sends to one phone per rolling hour. 0 disables.</summary>
|
||||
public int MaxPerHour { get; set; } = 5;
|
||||
/// <summary>Server-wide OTP-send backstop per rolling hour (SMS-bomb / cost cap). 0 disables.</summary>
|
||||
public int MaxGlobalPerHour { get; set; } = 300;
|
||||
}
|
||||
|
||||
/// <summary>Result of an OTP request, including a rate-limit retry hint.</summary>
|
||||
public readonly record struct OtpResult(bool Ok, string? DevCode, string? Error, int RetryAfterSeconds);
|
||||
|
||||
/// <summary>Generates, sends (Kavenegar) and verifies phone OTP codes.</summary>
|
||||
public sealed class OtpService
|
||||
{
|
||||
@@ -26,6 +37,11 @@ public sealed class OtpService
|
||||
private readonly ILogger<OtpService> _log;
|
||||
private readonly ConcurrentDictionary<string, Entry> _codes = new();
|
||||
|
||||
// Rate-limit logs (singleton service → fields persist across requests).
|
||||
private readonly ConcurrentDictionary<string, List<DateTime>> _sendLog = new();
|
||||
private readonly object _globalLock = new();
|
||||
private readonly List<DateTime> _globalLog = new();
|
||||
|
||||
private readonly record struct Entry(string Code, DateTime Expires, int Tries);
|
||||
|
||||
public OtpService(SmsOptions opts, ILogger<OtpService> log)
|
||||
@@ -38,28 +54,78 @@ public sealed class OtpService
|
||||
public bool IsDev => _opts.DevMode || string.IsNullOrWhiteSpace(_opts.ApiKey);
|
||||
|
||||
/// <summary>Generate a code, store it, and send the SMS. Returns devCode only in dev mode.</summary>
|
||||
public async Task<(bool ok, string? devCode)> Request(string phone)
|
||||
public async Task<OtpResult> Request(string phone)
|
||||
{
|
||||
phone = Normalize(phone);
|
||||
if (string.IsNullOrWhiteSpace(phone)) return (false, null);
|
||||
if (string.IsNullOrWhiteSpace(phone)) return new OtpResult(false, null, "INVALID_PHONE", 0);
|
||||
|
||||
var code = IsDev ? _opts.DevCode : Random.Shared.Next(10000, 100000).ToString();
|
||||
// Dev mode never sends an SMS (fixed code) → no cost, no rate limit.
|
||||
if (IsDev)
|
||||
{
|
||||
_codes[phone] = new Entry(_opts.DevCode, DateTime.UtcNow.AddSeconds(_opts.TtlSeconds), 0);
|
||||
return new OtpResult(true, _opts.DevCode, null, 0);
|
||||
}
|
||||
|
||||
// Real SMS: enforce per-phone cooldown + hourly cap + a global backstop.
|
||||
var limited = CheckAndRecordRate(phone);
|
||||
if (limited is { } lim) return lim;
|
||||
|
||||
var code = Random.Shared.Next(10000, 100000).ToString();
|
||||
_codes[phone] = new Entry(code, DateTime.UtcNow.AddSeconds(_opts.TtlSeconds), 0);
|
||||
|
||||
if (IsDev) return (true, _opts.DevCode);
|
||||
|
||||
try
|
||||
{
|
||||
await SendKavenegar(phone, code);
|
||||
return (true, null);
|
||||
return new OtpResult(true, null, null, 0);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_log.LogWarning(e, "OTP send failed for {Phone}", phone);
|
||||
return (false, null);
|
||||
return new OtpResult(false, null, "SMS_FAILED", 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an OTP-send attempt against the rate limits. Returns a RATE_LIMITED
|
||||
/// result (with retry-after seconds) when over a limit, or null when allowed.
|
||||
/// </summary>
|
||||
private OtpResult? CheckAndRecordRate(string phone)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var hour = TimeSpan.FromHours(1);
|
||||
|
||||
var log = _sendLog.GetOrAdd(phone, _ => new List<DateTime>());
|
||||
lock (log)
|
||||
{
|
||||
log.RemoveAll(t => now - t >= hour);
|
||||
if (log.Count > 0)
|
||||
{
|
||||
var since = now - log[^1];
|
||||
var cooldown = TimeSpan.FromSeconds(_opts.ResendCooldownSeconds);
|
||||
if (since < cooldown)
|
||||
return new OtpResult(false, null, "RATE_LIMITED", (int)Math.Ceiling((cooldown - since).TotalSeconds));
|
||||
}
|
||||
if (_opts.MaxPerHour > 0 && log.Count >= _opts.MaxPerHour)
|
||||
{
|
||||
var retry = (int)Math.Ceiling((hour - (now - log[0])).TotalSeconds);
|
||||
return new OtpResult(false, null, "RATE_LIMITED", Math.Max(1, retry));
|
||||
}
|
||||
log.Add(now); // reserve the slot
|
||||
}
|
||||
|
||||
if (_opts.MaxGlobalPerHour > 0)
|
||||
{
|
||||
lock (_globalLock)
|
||||
{
|
||||
_globalLog.RemoveAll(t => now - t >= hour);
|
||||
if (_globalLog.Count >= _opts.MaxGlobalPerHour)
|
||||
return new OtpResult(false, null, "RATE_LIMITED", 60);
|
||||
_globalLog.Add(now);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Verify a submitted code (single-use, time-boxed, max 5 tries).</summary>
|
||||
public bool Verify(string phone, string code)
|
||||
{
|
||||
|
||||
@@ -43,4 +43,10 @@ public sealed class GameHub : Hub
|
||||
public void RequestForfeit() => _manager.RequestForfeit(Uid);
|
||||
public void ConfirmForfeit() => _manager.ConfirmForfeit(Uid);
|
||||
public void DeclineForfeit() => _manager.DeclineForfeit(Uid);
|
||||
|
||||
/// <summary>Notify a chat peer that this user is typing (ephemeral, not stored).</summary>
|
||||
public Task Typing(string peerId) =>
|
||||
string.IsNullOrWhiteSpace(peerId)
|
||||
? Task.CompletedTask
|
||||
: Clients.User(peerId).SendAsync("typing", new { from = Uid });
|
||||
}
|
||||
|
||||
@@ -150,10 +150,13 @@ app.MapPost("/api/admin/site/links", (HttpRequest req, AdminOptions admin, SiteL
|
||||
// --- phone OTP (Kavenegar SMS) + email login ---
|
||||
app.MapPost("/api/auth/otp/request", async (OtpRequest req, OtpService otp) =>
|
||||
{
|
||||
var (ok, devCode) = await otp.Request(req.Phone);
|
||||
if (!ok) return Results.BadRequest(new { error = "SMS_FAILED" });
|
||||
// devCode is only populated in dev mode (no API key); null in production.
|
||||
return Results.Json(new { sent = true, phone = req.Phone, devCode });
|
||||
var r = await otp.Request(req.Phone);
|
||||
if (r.Ok)
|
||||
// devCode is only populated in dev mode (no API key); null in production.
|
||||
return Results.Json(new { sent = true, phone = req.Phone, devCode = r.DevCode });
|
||||
if (r.Error == "RATE_LIMITED")
|
||||
return Results.Json(new { error = "RATE_LIMITED", retryAfter = r.RetryAfterSeconds }, statusCode: 429);
|
||||
return Results.BadRequest(new { error = r.Error ?? "SMS_FAILED" });
|
||||
});
|
||||
|
||||
app.MapPost("/api/auth/otp/verify", async (OtpVerify req, OtpService otp, TokenService tokens, ProfileService profiles) =>
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"ApiKey": "",
|
||||
"Template": "hokmotp",
|
||||
"DevMode": false,
|
||||
"DevCode": "1234"
|
||||
"DevCode": "1234",
|
||||
"ResendCooldownSeconds": 60,
|
||||
"MaxPerHour": 5,
|
||||
"MaxGlobalPerHour": 300
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user