diff --git a/server/src/Hokm.Server/Auth/OtpService.cs b/server/src/Hokm.Server/Auth/OtpService.cs index 751dfdb..391eda8 100644 --- a/server/src/Hokm.Server/Auth/OtpService.cs +++ b/server/src/Hokm.Server/Auth/OtpService.cs @@ -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) --- */ + /// Minimum seconds between two OTP sends to the same phone (resend cooldown). + public int ResendCooldownSeconds { get; set; } = 60; + /// Max OTP sends to one phone per rolling hour. 0 disables. + public int MaxPerHour { get; set; } = 5; + /// Server-wide OTP-send backstop per rolling hour (SMS-bomb / cost cap). 0 disables. + public int MaxGlobalPerHour { get; set; } = 300; } +/// Result of an OTP request, including a rate-limit retry hint. +public readonly record struct OtpResult(bool Ok, string? DevCode, string? Error, int RetryAfterSeconds); + /// Generates, sends (Kavenegar) and verifies phone OTP codes. public sealed class OtpService { @@ -26,6 +37,11 @@ public sealed class OtpService private readonly ILogger _log; private readonly ConcurrentDictionary _codes = new(); + // Rate-limit logs (singleton service → fields persist across requests). + private readonly ConcurrentDictionary> _sendLog = new(); + private readonly object _globalLock = new(); + private readonly List _globalLog = new(); + private readonly record struct Entry(string Code, DateTime Expires, int Tries); public OtpService(SmsOptions opts, ILogger log) @@ -38,28 +54,78 @@ public sealed class OtpService public bool IsDev => _opts.DevMode || string.IsNullOrWhiteSpace(_opts.ApiKey); /// Generate a code, store it, and send the SMS. Returns devCode only in dev mode. - public async Task<(bool ok, string? devCode)> Request(string phone) + public async Task 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); } } + /// + /// 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. + /// + private OtpResult? CheckAndRecordRate(string phone) + { + var now = DateTime.UtcNow; + var hour = TimeSpan.FromHours(1); + + var log = _sendLog.GetOrAdd(phone, _ => new List()); + 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; + } + /// Verify a submitted code (single-use, time-boxed, max 5 tries). public bool Verify(string phone, string code) { diff --git a/server/src/Hokm.Server/Hubs/GameHub.cs b/server/src/Hokm.Server/Hubs/GameHub.cs index f350213..c84f9b5 100644 --- a/server/src/Hokm.Server/Hubs/GameHub.cs +++ b/server/src/Hokm.Server/Hubs/GameHub.cs @@ -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); + + /// Notify a chat peer that this user is typing (ephemeral, not stored). + public Task Typing(string peerId) => + string.IsNullOrWhiteSpace(peerId) + ? Task.CompletedTask + : Clients.User(peerId).SendAsync("typing", new { from = Uid }); } diff --git a/server/src/Hokm.Server/Program.cs b/server/src/Hokm.Server/Program.cs index 14df31c..1cf99e5 100644 --- a/server/src/Hokm.Server/Program.cs +++ b/server/src/Hokm.Server/Program.cs @@ -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) => diff --git a/server/src/Hokm.Server/appsettings.json b/server/src/Hokm.Server/appsettings.json index 2ebeaf1..3c37053 100644 --- a/server/src/Hokm.Server/appsettings.json +++ b/server/src/Hokm.Server/appsettings.json @@ -37,6 +37,9 @@ "ApiKey": "", "Template": "hokmotp", "DevMode": false, - "DevCode": "1234" + "DevCode": "1234", + "ResendCooldownSeconds": 60, + "MaxPerHour": 5, + "MaxGlobalPerHour": 300 } } diff --git a/src/components/GameTable.tsx b/src/components/GameTable.tsx index a1a977b..a114741 100644 --- a/src/components/GameTable.tsx +++ b/src/components/GameTable.tsx @@ -118,12 +118,12 @@ export function GameTable({ {/* Top HUD */}
-
+
{trump && } - ))} + {friends.length === 0 && ( +

{t("friends.empty")}

+ )} + {[...friends] + .sort((a, b) => statusRank(a.status) - statusRank(b.status)) + .map((f) => { + const inGame = f.status === "in-game"; + return ( + + ); + })}
@@ -210,7 +238,16 @@ function SeatCard({ {seat.kind === "bot" && 🤖} {seat.kind === "invited" ? ( - {t("room.waiting")} +
+ {t("room.waiting")} + +
) : ( role !== "you" && (