feat : kavenegar otp added
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user