using System.Collections.Concurrent; namespace Hokm.Server.Auth; /// /// SMS OTP config. Bound from the "Sms" config section / Sms__* env vars. /// Kavenegar verify/lookup: the panel template (e.g. "hokmotp") contains a /// %token placeholder that we fill with the generated code. /// public sealed class SmsOptions { public string Provider { get; set; } = "kavenegar"; public string ApiKey { get; set; } = ""; public string Template { get; set; } = "hokmotp"; /// When true (or no ApiKey), no SMS is sent and a fixed dev code is accepted. public bool DevMode { get; set; } = false; public string DevCode { get; set; } = "1234"; public int TtlSeconds { get; set; } = 120; } /// Generates, sends (Kavenegar) and verifies phone OTP codes. public sealed class OtpService { private static readonly HttpClient Http = new(); private readonly SmsOptions _opts; private readonly ILogger _log; private readonly ConcurrentDictionary _codes = new(); private readonly record struct Entry(string Code, DateTime Expires, int Tries); public OtpService(SmsOptions opts, ILogger log) { _opts = opts; _log = log; } /// Dev mode = explicitly on, or no API key configured. 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) { phone = Normalize(phone); if (string.IsNullOrWhiteSpace(phone)) return (false, null); var code = IsDev ? _opts.DevCode : 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); } catch (Exception e) { _log.LogWarning(e, "OTP send failed for {Phone}", phone); return (false, null); } } /// Verify a submitted code (single-use, time-boxed, max 5 tries). public bool Verify(string phone, string code) { phone = Normalize(phone); if (IsDev && code == _opts.DevCode) return true; if (!_codes.TryGetValue(phone, out var e)) return false; if (DateTime.UtcNow > e.Expires) { _codes.TryRemove(phone, out _); return false; } if (e.Tries >= 5) { _codes.TryRemove(phone, out _); return false; } if (e.Code != code) { _codes[phone] = e with { Tries = e.Tries + 1 }; return false; } _codes.TryRemove(phone, out _); return true; } private async Task SendKavenegar(string phone, string code) { // GET https://api.kavenegar.com/v1/{APIKEY}/verify/lookup.json?receptor=&token=&template= var url = $"https://api.kavenegar.com/v1/{_opts.ApiKey}/verify/lookup.json" + $"?receptor={Uri.EscapeDataString(phone)}" + $"&token={Uri.EscapeDataString(code)}" + $"&template={Uri.EscapeDataString(_opts.Template)}"; var resp = await Http.GetAsync(url); var body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"Kavenegar {(int)resp.StatusCode}: {body}"); } /// Normalize to Kavenegar's 09xxxxxxxxx form (handles +98 / 98 prefixes). private static string Normalize(string phone) { phone = (phone ?? "").Trim().Replace(" ", ""); if (phone.StartsWith("+98")) phone = "0" + phone[3..]; else if (phone.StartsWith("0098")) phone = "0" + phone[4..]; else if (phone.Length == 12 && phone.StartsWith("98")) phone = "0" + phone[2..]; return phone; } }