feat: OTP rate limit, private-room invite UX, in-game UI fixes
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 54s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m12s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m11s

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:
soroush.asadi
2026-06-14 00:30:20 +03:30
parent 78878efc22
commit bc695bc8e9
12 changed files with 257 additions and 58 deletions
+73 -7
View File
@@ -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)
{
+6
View File
@@ -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 });
}
+7 -4
View File
@@ -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) =>
+4 -1
View File
@@ -37,6 +37,9 @@
"ApiKey": "",
"Template": "hokmotp",
"DevMode": false,
"DevCode": "1234"
"DevCode": "1234",
"ResendCooldownSeconds": 60,
"MaxPerHour": 5,
"MaxGlobalPerHour": 300
}
}