From fdf4235fbd0fb90e337af0741c3528b8f07e0264 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Fri, 12 Jun 2026 23:50:33 +0330 Subject: [PATCH] feat(auth): real SMS OTP via Kavenegar (replaces the mock 1234 code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 4 + deploy/ENV_FILE.example | 106 +++++++--------------- docker-compose.yml | 4 + server/src/Hokm.Server/Auth/OtpService.cs | 99 ++++++++++++++++++++ server/src/Hokm.Server/Program.cs | 22 +++-- server/src/Hokm.Server/appsettings.json | 7 ++ 6 files changed, 161 insertions(+), 81 deletions(-) create mode 100644 server/src/Hokm.Server/Auth/OtpService.cs diff --git a/.gitignore b/.gitignore index c10bac5..ef5b172 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ # 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 /node_modules /.pnp diff --git a/deploy/ENV_FILE.example b/deploy/ENV_FILE.example index 668c49f..fc3a871 100644 --- a/deploy/ENV_FILE.example +++ b/deploy/ENV_FILE.example @@ -1,93 +1,49 @@ # ────────────────────────────────────────────────────────────────────────── -# Barg-e Vasat — ENV_FILE -# Paste the contents of this file (filled in) into the Gitea repo secret: -# https://git.soroushasadi.com/soroushdes/HokmPlay/settings/secrets → ENV_FILE -# The deploy job writes it verbatim to `.env`, which docker compose reads. -# -# 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. +# Barg-e Vasat — ENV_FILE TEMPLATE (placeholders only — NO real secrets here) +# Copy to deploy/ENV_FILE.local (git-ignored), fill real values, and paste the +# WHOLE thing into the Gitea repo secret ENV_FILE. Saving the secret REPLACES +# the entire file — always paste the complete contents. # ────────────────────────────────────────────────────────────────────────── -# Host ports (1500–1600 range so the stack coexists with manual dev on 3000/5005) +# Ports WEB_PORT=1500 API_PORT=1505 DB_PORT=1510 +SITE_PORT=1520 -# Database (postgres container) -POSTGRES_PASSWORD=change-me-strong-password +# Database — MUST match the existing postgres volume's password +POSTGRES_PASSWORD= # 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_AUDIENCE=hokm-clients -# Browser-facing API origin (host-mapped api port). -# If the browser is NOT on the deploy host, use the host LAN IP instead of -# localhost, e.g. http://172.28.144.1:1505 (localhost can be VPN-hijacked). -NEXT_PUBLIC_SERVER_URL=http://localhost:1505 +# URLs / CORS +NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir +NEXT_PUBLIC_APP_URL=https://app.bargevasat.ir +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. -CORS_ORIGINS=http://localhost:1500 +# ZarinPal +ZARINPAL_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 -# (no SSL) because the HTTPS mirror serves a partial cert chain that fresh -# 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/ +# Admin panel token (openssl rand -hex 24) +ADMIN_TOKEN= -# ZarinPal (sandbox for now — switch in admin/panel later) -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. +# In-app billing (Cafe Bazaar / Myket) — fill from the developer panels. IAB_PACKAGE_NAME=com.bargevasat.app -# Cafe Bazaar (pardakht dev API): create an OAuth client, do the one-time consent -# to obtain a refresh_token. https://pardakht.cafebazaar.ir/ -IAB_BAZAAR_CLIENT_ID= -IAB_BAZAAR_CLIENT_SECRET= -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_BAZAAR_CLIENT_ID= +IAB_BAZAAR_CLIENT_SECRET= +IAB_BAZAAR_REFRESH_TOKEN= +IAB_MYKET_ACCESS_TOKEN= IAB_ALLOW_UNVERIFIED=false -# ────────────────────────────────────────────────────────────────────────── -# Marketing site (bargevasat.ir) + subdomain split -# Game → app.bargevasat.ir ; marketing site → bargevasat.ir -# ────────────────────────────────────────────────────────────────────────── -SITE_PORT=1520 -# 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= -# JWT_KEY= -# 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= -# 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 +# SMS OTP (Kavenegar). Template "hokmotp" has a %token placeholder we fill with +# the code. Leave SMS_API_KEY empty for dev mode (no SMS sent, code = 1234). +SMS_PROVIDER=kavenegar +SMS_API_KEY= +SMS_TEMPLATE=hokmotp diff --git a/docker-compose.yml b/docker-compose.yml index 409999c..7ec3541 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,10 @@ services: Iab__BazaarRefreshToken: ${IAB_BAZAAR_REFRESH_TOKEN:-} Iab__MyketAccessToken: ${IAB_MYKET_ACCESS_TOKEN:-} 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__Token: ${ADMIN_TOKEN:-} # Where the admin-editable site-links JSON is persisted (mounted volume). diff --git a/server/src/Hokm.Server/Auth/OtpService.cs b/server/src/Hokm.Server/Auth/OtpService.cs new file mode 100644 index 0000000..751dfdb --- /dev/null +++ b/server/src/Hokm.Server/Auth/OtpService.cs @@ -0,0 +1,99 @@ +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; + } +} diff --git a/server/src/Hokm.Server/Program.cs b/server/src/Hokm.Server/Program.cs index eecb0c1..14df31c 100644 --- a/server/src/Hokm.Server/Program.cs +++ b/server/src/Hokm.Server/Program.cs @@ -52,6 +52,11 @@ var iab = builder.Configuration.GetSection("Iab").Get() ?? new IabOp builder.Services.AddSingleton(iab); builder.Services.AddSingleton(); +// --- SMS OTP (Kavenegar). No ApiKey ⇒ dev mode (fixed code, no SMS sent). --- +var sms = builder.Configuration.GetSection("Sms").Get() ?? new SmsOptions(); +builder.Services.AddSingleton(sms); +builder.Services.AddSingleton(); + // --- Marketing site links (admin-editable) + shared-token admin auth --- var admin = builder.Configuration.GetSection("Admin").Get() ?? 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); diff --git a/server/src/Hokm.Server/appsettings.json b/server/src/Hokm.Server/appsettings.json index c006073..2ebeaf1 100644 --- a/server/src/Hokm.Server/appsettings.json +++ b/server/src/Hokm.Server/appsettings.json @@ -31,5 +31,12 @@ "BazaarRefreshToken": "", "MyketAccessToken": "", "AllowUnverified": false + }, + "Sms": { + "Provider": "kavenegar", + "ApiKey": "", + "Template": "hokmotp", + "DevMode": false, + "DevCode": "1234" } }