feat(auth): real SMS OTP via Kavenegar (replaces the mock 1234 code)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 50s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 34s

- 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>
This commit is contained in:
soroush.asadi
2026-06-12 23:50:33 +03:30
parent 83d9c1c7d0
commit fdf4235fbd
6 changed files with 161 additions and 81 deletions
+99
View File
@@ -0,0 +1,99 @@
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;
}
}
+16 -6
View File
@@ -52,6 +52,11 @@ var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? new IabOp
builder.Services.AddSingleton(iab);
builder.Services.AddSingleton<IabService>();
// --- SMS OTP (Kavenegar). No ApiKey ⇒ dev mode (fixed code, no SMS sent). ---
var sms = builder.Configuration.GetSection("Sms").Get<SmsOptions>() ?? new SmsOptions();
builder.Services.AddSingleton(sms);
builder.Services.AddSingleton<OtpService>();
// --- Marketing site links (admin-editable) + shared-token admin auth ---
var admin = builder.Configuration.GetSection("Admin").Get<AdminOptions>() ?? new AdminOptions();
builder.Services.AddSingleton(admin);
@@ -142,13 +147,18 @@ app.MapPost("/api/admin/site/links", (HttpRequest req, AdminOptions admin, SiteL
return Results.Json(s.Update(body), JsonOpts.Default);
});
// --- dev auth (mock OTP + email). Replace with the V2 Identity Service later. ---
app.MapPost("/api/auth/otp/request", (OtpRequest req) =>
Results.Json(new { devCode = "1234", phone = req.Phone }));
app.MapPost("/api/auth/otp/verify", async (OtpVerify req, TokenService tokens, ProfileService profiles) =>
// --- phone OTP (Kavenegar SMS) + email login ---
app.MapPost("/api/auth/otp/request", async (OtpRequest req, OtpService otp) =>
{
if (req.Code != "1234")
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 });
});
app.MapPost("/api/auth/otp/verify", async (OtpVerify req, OtpService otp, TokenService tokens, ProfileService profiles) =>
{
if (!otp.Verify(req.Phone, req.Code))
return Results.BadRequest(new { error = "INVALID_CODE" });
var userId = "phone:" + req.Phone;
var p = await profiles.GetOrCreate(userId, req.Name);
+7
View File
@@ -31,5 +31,12 @@
"BazaarRefreshToken": "",
"MyketAccessToken": "",
"AllowUnverified": false
},
"Sms": {
"Provider": "kavenegar",
"ApiKey": "",
"Template": "hokmotp",
"DevMode": false,
"DevCode": "1234"
}
}