fdf4235fbd
- OtpService: generates a 5-digit code, stores it (in-memory, 120s TTL, max 5 tries, single-use), and sends it via Kavenegar verify/lookup (template "hokmotp", %token = code). Normalizes +98/98 → 09xxxxxxxxx. - /api/auth/otp/request + /verify now use it. No SMS_API_KEY ⇒ dev mode (accepts a fixed code, returns devCode for local testing). - Config: Sms section (appsettings) + Sms__* compose mapping + SMS_* in the ENV_FILE template. Security: sanitized deploy/ENV_FILE.example back to placeholders (it had picked up real secrets) and added /deploy/ENV_FILE.local to .gitignore as the real master copy (never committed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 lines
3.9 KiB
C#
100 lines
3.9 KiB
C#
using System.Collections.Concurrent;
|
|
|
|
namespace Hokm.Server.Auth;
|
|
|
|
/// <summary>
|
|
/// SMS OTP config. Bound from the "Sms" config section / <c>Sms__*</c> env vars.
|
|
/// Kavenegar verify/lookup: the panel template (e.g. "hokmotp") contains a
|
|
/// <c>%token</c> placeholder that we fill with the generated code.
|
|
/// </summary>
|
|
public sealed class SmsOptions
|
|
{
|
|
public string Provider { get; set; } = "kavenegar";
|
|
public string ApiKey { get; set; } = "";
|
|
public string Template { get; set; } = "hokmotp";
|
|
/// <summary>When true (or no ApiKey), no SMS is sent and a fixed dev code is accepted.</summary>
|
|
public bool DevMode { get; set; } = false;
|
|
public string DevCode { get; set; } = "1234";
|
|
public int TtlSeconds { get; set; } = 120;
|
|
}
|
|
|
|
/// <summary>Generates, sends (Kavenegar) and verifies phone OTP codes.</summary>
|
|
public sealed class OtpService
|
|
{
|
|
private static readonly HttpClient Http = new();
|
|
private readonly SmsOptions _opts;
|
|
private readonly ILogger<OtpService> _log;
|
|
private readonly ConcurrentDictionary<string, Entry> _codes = new();
|
|
|
|
private readonly record struct Entry(string Code, DateTime Expires, int Tries);
|
|
|
|
public OtpService(SmsOptions opts, ILogger<OtpService> log)
|
|
{
|
|
_opts = opts;
|
|
_log = log;
|
|
}
|
|
|
|
/// <summary>Dev mode = explicitly on, or no API key configured.</summary>
|
|
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)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verify a submitted code (single-use, time-boxed, max 5 tries).</summary>
|
|
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}");
|
|
}
|
|
|
|
/// <summary>Normalize to Kavenegar's 09xxxxxxxxx form (handles +98 / 98 prefixes).</summary>
|
|
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;
|
|
}
|
|
}
|