feat : kavenegar otp added

This commit is contained in:
soroush.asadi
2026-05-29 10:18:47 +03:30
parent 27e61d257e
commit 16cff8730b
22 changed files with 502 additions and 34 deletions
+39 -1
View File
@@ -19,17 +19,23 @@ public class AuthController : ControllerBase
private readonly IValidator<SendOtpRequest> _sendOtpValidator;
private readonly IValidator<VerifyOtpRequest> _verifyOtpValidator;
private readonly IValidator<RefreshTokenRequest> _refreshValidator;
private readonly IValidator<RegisterRequest> _registerValidator;
private readonly IValidator<VerifyRegisterRequest> _verifyRegisterValidator;
public AuthController(
IAuthService authService,
IValidator<SendOtpRequest> sendOtpValidator,
IValidator<VerifyOtpRequest> verifyOtpValidator,
IValidator<RefreshTokenRequest> refreshValidator)
IValidator<RefreshTokenRequest> refreshValidator,
IValidator<RegisterRequest> registerValidator,
IValidator<VerifyRegisterRequest> verifyRegisterValidator)
{
_authService = authService;
_sendOtpValidator = sendOtpValidator;
_verifyOtpValidator = verifyOtpValidator;
_refreshValidator = refreshValidator;
_registerValidator = registerValidator;
_verifyRegisterValidator = verifyRegisterValidator;
}
[HttpPost("send-otp")]
@@ -78,6 +84,37 @@ public class AuthController : ControllerBase
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("register")]
[EnableRateLimiting("auth-otp")]
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
{
var validation = await _registerValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid)
return BadRequest(ValidationError(validation));
var (success, data, code, message) = await _authService.RegisterAsync(request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<SendOtpResponse>(true, data));
}
[HttpPost("verify-register")]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> VerifyRegister([FromBody] VerifyRegisterRequest request, CancellationToken cancellationToken)
{
var validation = await _verifyRegisterValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid)
return BadRequest(ValidationError(validation));
var (success, data, code, message) = await _authService.VerifyRegisterAsync(request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpGet("me")]
[Authorize]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
@@ -120,6 +157,7 @@ public class AuthController : ControllerBase
new ApiResponse<object>(false, null, new ApiError(code, message))),
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
"ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message)))
};
}
+6
View File
@@ -6,6 +6,12 @@ public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null)
public record RefreshTokenRequest(string RefreshToken);
/// <summary>Step 1 of self-registration: send OTP to a new phone number.</summary>
public record RegisterRequest(string Phone, string CafeName);
/// <summary>Step 2 of self-registration: verify OTP and create the cafe account.</summary>
public record VerifyRegisterRequest(string Phone, string Code);
public record AuthTokenResponse(
string AccessToken,
string RefreshToken,
+135
View File
@@ -1,5 +1,7 @@
using Meezi.API.Models.Auth;
using Meezi.API.Security;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Data;
@@ -162,6 +164,139 @@ public class AuthService : IAuthService
return (true, tokens, null, null);
}
public async Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> RegisterAsync(
RegisterRequest request,
CancellationToken cancellationToken = default)
{
var phone = PhoneNormalizer.Normalize(request.Phone);
var cafeName = request.CafeName.Trim();
var redis = _redis.GetDatabase();
var maxAttempts = _configuration.GetValue("Auth:MaxOtpAttemptsPerHour", DefaultMaxOtpAttemptsPerHour);
if (_http.HttpContext is not null)
{
var ip = ClientIpResolver.GetClientIp(_http.HttpContext);
var ipCheck = await _abuse.CheckAuthOtpByIpAsync(ip, cancellationToken);
if (!ipCheck.Allowed)
return (false, null, ipCheck.ErrorCode, ipCheck.Message);
}
// Check if this phone already owns a cafe — suggest login instead
var alreadyOwner = await _db.Employees
.AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken);
if (alreadyOwner)
return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in.");
var attemptsKey = $"otp:attempts:{phone}";
if (maxAttempts > 0)
{
var attempts = await redis.StringGetAsync(attemptsKey);
if (attempts.HasValue && (int)attempts >= maxAttempts)
return (false, null, "RATE_LIMITED", "Too many OTP requests. Try again later.");
}
var otp = Random.Shared.Next(100000, 999999).ToString();
await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
// Store the cafe name alongside the OTP so verify-register can create the cafe
await redis.StringSetAsync($"reg_meta:{phone}", cafeName, TimeSpan.FromSeconds(OtpTtlSeconds));
if (string.IsNullOrWhiteSpace(_configuration["Kavenegar:ApiKey"]))
_logger.LogWarning("DEV REGISTER OTP for {Phone}: {Otp} (configure Kavenegar:ApiKey to send SMS)", phone, otp);
try
{
await _smsService.SendOtpAsync(phone, otp, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send registration OTP SMS");
return (false, null, "SMS_FAILED", "Could not send verification code.");
}
if (maxAttempts > 0)
{
var newAttempts = await redis.StringIncrementAsync(attemptsKey);
if (newAttempts == 1)
await redis.KeyExpireAsync(attemptsKey, TimeSpan.FromHours(1));
}
_logger.LogInformation("Registration OTP sent for phone ending {Suffix}", phone[^4..]);
return (true, new SendOtpResponse(true, OtpTtlSeconds), null, null);
}
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyRegisterAsync(
VerifyRegisterRequest request,
CancellationToken cancellationToken = default)
{
var phone = PhoneNormalizer.Normalize(request.Phone);
var code = OtpNormalizer.Normalize(request.Code);
if (!OtpNormalizer.IsValidSixDigitCode(code))
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
var redis = _redis.GetDatabase();
var storedOtp = await redis.StringGetAsync($"otp:{phone}");
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
var cafeName = (await redis.StringGetAsync($"reg_meta:{phone}")).ToString();
if (string.IsNullOrWhiteSpace(cafeName))
return (false, null, "REGISTRATION_EXPIRED", "Registration session expired. Please start again.");
// Double-check no owner was created in the meantime (race condition guard)
var alreadyOwner = await _db.Employees
.AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken);
if (alreadyOwner)
{
await redis.KeyDeleteAsync($"otp:{phone}");
await redis.KeyDeleteAsync($"reg_meta:{phone}");
return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in.");
}
// Generate a unique slug
var slug = await GenerateUniqueSlugAsync(cancellationToken);
var cafe = new Cafe
{
Name = cafeName,
Slug = slug,
PreferredLanguage = "fa",
PlanTier = PlanTier.Free,
};
var owner = new Employee
{
CafeId = cafe.Id,
Name = cafeName, // owner display name defaults to cafe name until they update it
Phone = phone,
Role = EmployeeRole.Owner,
};
_db.Cafes.Add(cafe);
_db.Employees.Add(owner);
await _db.SaveChangesAsync(cancellationToken);
await redis.KeyDeleteAsync($"otp:{phone}");
await redis.KeyDeleteAsync($"reg_meta:{phone}");
_logger.LogInformation("New cafe registered: {CafeId} by phone ending {Suffix}", cafe.Id, phone[^4..]);
var tokens = await IssueTokensAsync(owner, cafe, cancellationToken);
return (true, tokens, null, null);
}
private async Task<string> GenerateUniqueSlugAsync(CancellationToken ct)
{
string slug;
do
{
// e.g. "cafe-a3f9b2c"
slug = "cafe-" + Guid.NewGuid().ToString("N")[..7];
} while (await _db.Cafes.AnyAsync(c => c.Slug == slug, ct));
return slug;
}
private async Task<AuthTokenResponse> IssueTokensAsync(
Core.Entities.Employee employee,
Core.Entities.Cafe cafe,
+8
View File
@@ -15,4 +15,12 @@ public interface IAuthService
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
RefreshTokenRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> RegisterAsync(
RegisterRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyRegisterAsync(
VerifyRegisterRequest request,
CancellationToken cancellationToken = default);
}
@@ -37,3 +37,34 @@ public class RefreshTokenRequestValidator : AbstractValidator<RefreshTokenReques
RuleFor(x => x.RefreshToken).NotEmpty();
}
}
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
{
public RegisterRequestValidator()
{
RuleFor(x => x.Phone)
.NotEmpty()
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
.WithMessage("Invalid Iranian mobile number.");
RuleFor(x => x.CafeName)
.NotEmpty()
.MaximumLength(100)
.WithMessage("Cafe name must be between 1 and 100 characters.");
}
}
public class VerifyRegisterRequestValidator : AbstractValidator<VerifyRegisterRequest>
{
public VerifyRegisterRequestValidator()
{
RuleFor(x => x.Phone)
.NotEmpty()
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
.WithMessage("Invalid Iranian mobile number.");
RuleFor(x => x.Code)
.Must(OtpNormalizer.IsValidSixDigitCode)
.WithMessage("OTP must be 6 digits.");
}
}
@@ -22,3 +22,11 @@ public class VerifyOtpRequestValidator : AbstractValidator<VerifyOtpRequest>
.WithMessage("OTP must be 6 digits.");
}
}
public class RefreshTokenRequestValidator : AbstractValidator<RefreshTokenRequest>
{
public RefreshTokenRequestValidator()
{
RuleFor(x => x.RefreshToken).NotEmpty();
}
}
@@ -37,6 +37,7 @@ public static class DemoMenuCatalog
public static IReadOnlyList<CategorySeed> Categories { get; } =
[
new("cat_demo_coffee", "قهوه تخصصی", "Specialty Coffee", "قهوة متخصصة", 0, IconPresetId: "drinks-hot"),
new("cat_demo_drinks", "نوشیدنی گرم", "Hot drinks", "مشروبات ساخنة", 1, IconPresetId: "drinks-hot"),
new("cat_demo_cold", "نوشیدنی سرد", "Cold drinks", "مشروبات باردة", 2, IconPresetId: "drinks-cold"),
new("cat_demo_breakfast", "صبحانه", "Breakfast", "فطور", 3, IconPresetId: "breakfast"),
@@ -47,6 +48,28 @@ public static class DemoMenuCatalog
public static IReadOnlyList<ItemSeed> Items { get; } =
[
// ── Specialty Coffee (20 items) ──────────────────────────────────────
new("item_demo_cf_espresso", "cat_demo_coffee", "اسپرسو", "Espresso", "إسبريسو", "دوبل شات، تازه آسیاب", 65_000, 0, "espresso"),
new("item_demo_cf_doppio", "cat_demo_coffee", "دوپیو", "Doppio", "دوبيو", "دو شات اسپرسو", 80_000, 0, "espresso"),
new("item_demo_cf_ristretto", "cat_demo_coffee", "ریسترتو", "Ristretto", "ريستريتو", "کوتاه و غلیظ", 75_000, 0, "espresso"),
new("item_demo_cf_lungo", "cat_demo_coffee", "لونگو", "Lungo", "لونغو", "اسپرسو کشیده", 70_000, 0, "espresso"),
new("item_demo_cf_americano", "cat_demo_coffee", "آمریکانو", "Americano", "أمريكانو", "اسپرسو و آب داغ", 75_000, 0, "cappuccino"),
new("item_demo_cf_cappuccino", "cat_demo_coffee", "کاپوچینو", "Cappuccino", "كابتشينو", "شیر بخار و فوم", 110_000, 10, "cappuccino"),
new("item_demo_cf_latte", "cat_demo_coffee", "لاته", "Latte", "لاتيه", "شیر بخار گرفته", 120_000, 0, "latte"),
new("item_demo_cf_flat_white", "cat_demo_coffee", "فلت وایت", "Flat White", "فلات وايت", "میکرو فوم نرم", 115_000, 0, "latte"),
new("item_demo_cf_cortado", "cat_demo_coffee", "کورتادو", "Cortado", "كورتادو", "نسبت مساوی قهوه و شیر", 95_000, 0, "espresso"),
new("item_demo_cf_macchiato", "cat_demo_coffee", "ماکیاتو", "Macchiato", "ماكياتو", "اسپرسو با کمی فوم", 90_000, 0, "cappuccino"),
new("item_demo_cf_mocha", "cat_demo_coffee", "موکا", "Mocha", "موكا", "شکلات تلخ و اسپرسو", 135_000, 0, "mocha"),
new("item_demo_cf_affogato", "cat_demo_coffee", "افوگاتو", "Affogato", "أفوغاتو", "اسپرسو روی بستنی وانیل", 140_000, 0, "espresso"),
new("item_demo_cf_cold_brew", "cat_demo_coffee", "کولد برو", "Cold Brew", "كولد برو", "دم ۱۲ ساعته", 140_000, 0, "iced_coffee"),
new("item_demo_cf_iced_latte", "cat_demo_coffee", "آیس لاته", "Iced Latte", "آيس لاتيه", "سرد و خنک", 130_000, 0, "iced_coffee"),
new("item_demo_cf_iced_ameri", "cat_demo_coffee", "آیس آمریکانو", "Iced Americano", "آيس أمريكانو", null, 95_000, 0, "iced_coffee"),
new("item_demo_cf_nitro", "cat_demo_coffee", "نیترو کافی", "Nitro Cold Brew", "نيترو كافي", "با نیتروژن کربن‌دار", 155_000, 0, "iced_coffee"),
new("item_demo_cf_v60", "cat_demo_coffee", "V60", "Pour Over V60", "في 60", "دم‌آوری دستی", 145_000, 0, "espresso"),
new("item_demo_cf_aeropress", "cat_demo_coffee", "ایروپرس", "AeroPress", "إيروبريس", "طعم نرم و کامل", 140_000, 0, "espresso"),
new("item_demo_cf_turkish", "cat_demo_coffee", "قهوه ترک", "Turkish Coffee", "قهوة تركية", "سنتی با هل", 80_000, 0, "espresso"),
new("item_demo_cf_specialty", "cat_demo_coffee", "قهوه تک‌خاستگاه", "Single Origin", "أحادي المنشأ", "هر هفته از منشأ جدید", 165_000, 0, "espresso"),
// Hot drinks
new("item_demo_espresso", "cat_demo_drinks", "اسپرسو", "Espresso", "إسبريسو", "دوبل یا سینگل", 65_000, 0, "espresso"),
new("item_demo_americano", "cat_demo_drinks", "آمریکانو", "Americano", "أمريكانو", null, 75_000, 0, "cappuccino"),
@@ -129,6 +152,10 @@ file static class DemoMenuCategoryKinds
public static MenuItemVisualKind KindFor(string itemId, string food101Class)
{
// All specialty coffee items are drinks
if (itemId.StartsWith("item_demo_cf_", StringComparison.Ordinal))
return MenuItemVisualKind.Drink;
if (itemId.Contains("demo_iced", StringComparison.Ordinal)
|| itemId.Contains("demo_cold", StringComparison.Ordinal)
|| itemId.Contains("demo_lemonade", StringComparison.Ordinal)
@@ -47,7 +47,7 @@ public static class DevelopmentDataSeeder
CafeId = cafe.Id,
BranchId = "branch_demo_main",
Name = "مدیر دمو",
Phone = "09121234567",
Phone = "09212273138",
Role = EmployeeRole.Owner,
BaseSalary = 0
};
@@ -97,10 +97,12 @@ public static class DevelopmentDataSeeder
await SeedDemoOpenShiftsAsync(db, cafe.Id, logger);
var ownerEmp = await db.Employees.FirstOrDefaultAsync(e => e.Id == "emp_demo_owner");
if (ownerEmp is not null && ownerEmp.BranchId is null)
if (ownerEmp is not null)
{
ownerEmp.BranchId = "branch_demo_main";
await db.SaveChangesAsync();
var changed = false;
if (ownerEmp.BranchId is null) { ownerEmp.BranchId = "branch_demo_main"; changed = true; }
if (ownerEmp.Phone != "09212273138") { ownerEmp.Phone = "09212273138"; changed = true; }
if (changed) await db.SaveChangesAsync();
}
await DemoEmployeesSeeder.EnsureEmployeesAsync(db, cafe.Id, logger);