feat(auth): real SMS OTP via Kavenegar (replaces the mock 1234 code)
- 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:
@@ -1,5 +1,9 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# local secrets master copy (real ENV_FILE values — NEVER commit)
|
||||||
|
/deploy/ENV_FILE.local
|
||||||
|
*.env.local
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
|
|||||||
+31
-75
@@ -1,93 +1,49 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
# Barg-e Vasat — ENV_FILE
|
# Barg-e Vasat — ENV_FILE TEMPLATE (placeholders only — NO real secrets here)
|
||||||
# Paste the contents of this file (filled in) into the Gitea repo secret:
|
# Copy to deploy/ENV_FILE.local (git-ignored), fill real values, and paste the
|
||||||
# https://git.soroushasadi.com/soroushdes/HokmPlay/settings/secrets → ENV_FILE
|
# WHOLE thing into the Gitea repo secret ENV_FILE. Saving the secret REPLACES
|
||||||
# The deploy job writes it verbatim to `.env`, which docker compose reads.
|
# the entire file — always paste the complete contents.
|
||||||
#
|
|
||||||
# NOTE: NEXT_PUBLIC_SERVER_URL is baked into the web bundle at BUILD time —
|
|
||||||
# changing it requires a new CI run (push a commit) to take effect.
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Host ports (1500–1600 range so the stack coexists with manual dev on 3000/5005)
|
# Ports
|
||||||
WEB_PORT=1500
|
WEB_PORT=1500
|
||||||
API_PORT=1505
|
API_PORT=1505
|
||||||
DB_PORT=1510
|
DB_PORT=1510
|
||||||
|
SITE_PORT=1520
|
||||||
|
|
||||||
# Database (postgres container)
|
# Database — MUST match the existing postgres volume's password
|
||||||
POSTGRES_PASSWORD=change-me-strong-password
|
POSTGRES_PASSWORD=<strong-password>
|
||||||
|
|
||||||
# JWT — generate with: openssl rand -hex 32
|
# JWT — generate with: openssl rand -hex 32
|
||||||
JWT_KEY=CHANGE-ME-to-a-32+char-random-secret
|
JWT_KEY=<32+char-random-secret>
|
||||||
JWT_ISSUER=hokm
|
JWT_ISSUER=hokm
|
||||||
JWT_AUDIENCE=hokm-clients
|
JWT_AUDIENCE=hokm-clients
|
||||||
|
|
||||||
# Browser-facing API origin (host-mapped api port).
|
# URLs / CORS
|
||||||
# If the browser is NOT on the deploy host, use the host LAN IP instead of
|
NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir
|
||||||
# localhost, e.g. http://172.28.144.1:1505 (localhost can be VPN-hijacked).
|
NEXT_PUBLIC_APP_URL=https://app.bargevasat.ir
|
||||||
NEXT_PUBLIC_SERVER_URL=http://localhost:1505
|
NEXT_PUBLIC_SITE_URL=https://bargevasat.ir
|
||||||
|
CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir,https://app.bargevasat.ir
|
||||||
|
|
||||||
# Origins allowed by the API's CORS (comma-separated). Must include the web URL.
|
# ZarinPal
|
||||||
CORS_ORIGINS=http://localhost:1500
|
ZARINPAL_MERCHANT_ID=<your-merchant-id>
|
||||||
|
ZARINPAL_SANDBOX=false
|
||||||
|
ZARINPAL_CALLBACK_URL=https://api.bargevasat.ir/api/coins/pay/callback
|
||||||
|
ZARINPAL_CLIENT_RETURN_URL=https://app.bargevasat.ir
|
||||||
|
|
||||||
# Package mirrors used during Docker builds. Default to the plain-HTTP Nexus
|
# Admin panel token (openssl rand -hex 24)
|
||||||
# (no SSL) because the HTTPS mirror serves a partial cert chain that fresh
|
ADMIN_TOKEN=<admin-token>
|
||||||
# container trust stores reject. Override only if your Nexus moves.
|
|
||||||
# NUGET_INDEX=http://171.22.25.73:8081/repository/nuget-group/index.json
|
|
||||||
# NPM_REGISTRY=http://171.22.25.73:8081/repository/npm-group/
|
|
||||||
|
|
||||||
# ZarinPal (sandbox for now — switch in admin/panel later)
|
# In-app billing (Cafe Bazaar / Myket) — fill from the developer panels.
|
||||||
ZARINPAL_MERCHANT_ID=299685fb-cadf-4dfc-98e2-d4af5d81528d
|
|
||||||
ZARINPAL_SANDBOX=true
|
|
||||||
ZARINPAL_CALLBACK_URL=http://localhost:1505/api/coins/pay/callback
|
|
||||||
ZARINPAL_CLIENT_RETURN_URL=http://localhost:1500
|
|
||||||
|
|
||||||
# Store in-app billing (Cafe Bazaar / Myket) — fill from the developer panels.
|
|
||||||
# SKU == coin-pack id (p1/p2/…). Coins are credited only after the purchase
|
|
||||||
# token verifies server-to-server.
|
|
||||||
IAB_PACKAGE_NAME=com.bargevasat.app
|
IAB_PACKAGE_NAME=com.bargevasat.app
|
||||||
# Cafe Bazaar (pardakht dev API): create an OAuth client, do the one-time consent
|
IAB_BAZAAR_CLIENT_ID=<bazaar-client-id>
|
||||||
# to obtain a refresh_token. https://pardakht.cafebazaar.ir/
|
IAB_BAZAAR_CLIENT_SECRET=<bazaar-client-secret>
|
||||||
IAB_BAZAAR_CLIENT_ID=
|
IAB_BAZAAR_REFRESH_TOKEN=<bazaar-refresh-token>
|
||||||
IAB_BAZAAR_CLIENT_SECRET=
|
IAB_MYKET_ACCESS_TOKEN=<myket-access-token>
|
||||||
IAB_BAZAAR_REFRESH_TOKEN=
|
|
||||||
# Myket developer panel → API access token.
|
|
||||||
IAB_MYKET_ACCESS_TOKEN=
|
|
||||||
# DEV ONLY: credit purchases WITHOUT verifying (set true to test before you have
|
|
||||||
# store creds). NEVER true in production.
|
|
||||||
IAB_ALLOW_UNVERIFIED=false
|
IAB_ALLOW_UNVERIFIED=false
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
# SMS OTP (Kavenegar). Template "hokmotp" has a %token placeholder we fill with
|
||||||
# Marketing site (bargevasat.ir) + subdomain split
|
# the code. Leave SMS_API_KEY empty for dev mode (no SMS sent, code = 1234).
|
||||||
# Game → app.bargevasat.ir ; marketing site → bargevasat.ir
|
SMS_PROVIDER=kavenegar
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
SMS_API_KEY=<kavenegar-api-key>
|
||||||
SITE_PORT=1520
|
SMS_TEMPLATE=hokmotp
|
||||||
# Browser-facing URLs baked into the marketing site at BUILD time:
|
|
||||||
NEXT_PUBLIC_APP_URL=https://app.bargevasat.ir # the game (CTA buttons)
|
|
||||||
NEXT_PUBLIC_SITE_URL=https://bargevasat.ir # canonical/SEO base
|
|
||||||
# (NEXT_PUBLIC_SERVER_URL above is reused by the site to read store links.)
|
|
||||||
|
|
||||||
# Admin panel (edit Bazaar/Myket/iOS links at /admin). Shared-token auth.
|
|
||||||
# Generate with: openssl rand -hex 24
|
|
||||||
ADMIN_TOKEN=7ec8b2b242695de7d2692185acb4f1d345a589866ddd2de6
|
|
||||||
|
|
||||||
# With the split, set these too (game bundle + CORS for all 3 hosts):
|
|
||||||
# NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir
|
|
||||||
# CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir,https://app.bargevasat.ir
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
|
||||||
# PRODUCTION (bargevasat.ir) — use these values instead of the local ones above,
|
|
||||||
# and deploy with the Caddy overlay (see PRODUCTION.md). DNS: bargevasat.ir,
|
|
||||||
# www, api → server IP; open 80/443. Caddy fronts TLS, so host ports are internal.
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
|
||||||
# WEB_PORT=1500
|
|
||||||
# API_PORT=1505
|
|
||||||
# DB_PORT=1510
|
|
||||||
# POSTGRES_PASSWORD=<strong>
|
|
||||||
# JWT_KEY=<openssl rand -hex 32>
|
|
||||||
# NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir # baked at web build time
|
|
||||||
# CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir
|
|
||||||
# ZARINPAL_MERCHANT_ID=<live-merchant-id>
|
|
||||||
# ZARINPAL_SANDBOX=false
|
|
||||||
# ZARINPAL_CALLBACK_URL=https://api.bargevasat.ir/api/coins/pay/callback
|
|
||||||
# ZARINPAL_CLIENT_RETURN_URL=https://bargevasat.ir
|
|
||||||
# IAB_ALLOW_UNVERIFIED=false # fill the IAB_* creds from the Bazaar panel post-publish
|
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ services:
|
|||||||
Iab__BazaarRefreshToken: ${IAB_BAZAAR_REFRESH_TOKEN:-}
|
Iab__BazaarRefreshToken: ${IAB_BAZAAR_REFRESH_TOKEN:-}
|
||||||
Iab__MyketAccessToken: ${IAB_MYKET_ACCESS_TOKEN:-}
|
Iab__MyketAccessToken: ${IAB_MYKET_ACCESS_TOKEN:-}
|
||||||
Iab__AllowUnverified: ${IAB_ALLOW_UNVERIFIED:-false}
|
Iab__AllowUnverified: ${IAB_ALLOW_UNVERIFIED:-false}
|
||||||
|
# SMS OTP (Kavenegar). Empty key ⇒ dev mode (no SMS, accepts the dev code).
|
||||||
|
Sms__Provider: ${SMS_PROVIDER:-kavenegar}
|
||||||
|
Sms__ApiKey: ${SMS_API_KEY:-}
|
||||||
|
Sms__Template: ${SMS_TEMPLATE:-hokmotp}
|
||||||
# Admin panel (marketing-site links editor) — shared-token auth.
|
# Admin panel (marketing-site links editor) — shared-token auth.
|
||||||
Admin__Token: ${ADMIN_TOKEN:-}
|
Admin__Token: ${ADMIN_TOKEN:-}
|
||||||
# Where the admin-editable site-links JSON is persisted (mounted volume).
|
# Where the admin-editable site-links JSON is persisted (mounted volume).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,11 @@ var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? new IabOp
|
|||||||
builder.Services.AddSingleton(iab);
|
builder.Services.AddSingleton(iab);
|
||||||
builder.Services.AddSingleton<IabService>();
|
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 ---
|
// --- Marketing site links (admin-editable) + shared-token admin auth ---
|
||||||
var admin = builder.Configuration.GetSection("Admin").Get<AdminOptions>() ?? new AdminOptions();
|
var admin = builder.Configuration.GetSection("Admin").Get<AdminOptions>() ?? new AdminOptions();
|
||||||
builder.Services.AddSingleton(admin);
|
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);
|
return Results.Json(s.Update(body), JsonOpts.Default);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- dev auth (mock OTP + email). Replace with the V2 Identity Service later. ---
|
// --- phone OTP (Kavenegar SMS) + email login ---
|
||||||
app.MapPost("/api/auth/otp/request", (OtpRequest req) =>
|
app.MapPost("/api/auth/otp/request", async (OtpRequest req, OtpService otp) =>
|
||||||
Results.Json(new { devCode = "1234", phone = req.Phone }));
|
|
||||||
|
|
||||||
app.MapPost("/api/auth/otp/verify", async (OtpVerify req, TokenService tokens, ProfileService profiles) =>
|
|
||||||
{
|
{
|
||||||
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" });
|
return Results.BadRequest(new { error = "INVALID_CODE" });
|
||||||
var userId = "phone:" + req.Phone;
|
var userId = "phone:" + req.Phone;
|
||||||
var p = await profiles.GetOrCreate(userId, req.Name);
|
var p = await profiles.GetOrCreate(userId, req.Name);
|
||||||
|
|||||||
@@ -31,5 +31,12 @@
|
|||||||
"BazaarRefreshToken": "",
|
"BazaarRefreshToken": "",
|
||||||
"MyketAccessToken": "",
|
"MyketAccessToken": "",
|
||||||
"AllowUnverified": false
|
"AllowUnverified": false
|
||||||
|
},
|
||||||
|
"Sms": {
|
||||||
|
"Provider": "kavenegar",
|
||||||
|
"ApiKey": "",
|
||||||
|
"Template": "hokmotp",
|
||||||
|
"DevMode": false,
|
||||||
|
"DevCode": "1234"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user